11. 输入框光标与渲染
输入框这条线的最后一篇,把三块拼起来:光标坐标计算 cursorPosition.ts、帧渲染 ComposerFrame.tsx、以及编排一切的 index.tsx。
编排:PromptComposer/index.tsx
组件本身不持有文本状态(在第 8 篇的原子里),它把各块串起来:
export function PromptComposer({ columns, onSubmit, isActive = true, maxBytes = PROMPT_MAX_BYTES,
maxVisibleLines = DEFAULT_COMPOSER_VISIBLE_LINES, cursorTop, onVisibleRowsChange }) {
const state = useAtomValue(composerStateAtom);
const composerRef = useRef<DOMElement | null>(null);
const composerMetrics = useBoxMetrics(composerRef);
const { setCursorPosition } = useCursor();
usePromptComposerInput({ isActive, maxBytes, onSubmit, state });
const inputColumns = Math.max(1, columns - PROMPT_PREFIX.length);
const visiblePrompt = formatVisiblePromptView(state.text, inputColumns, maxVisibleLines, state.cursorIndex);
const visibleText = visiblePrompt.text;
const shouldRenderBackground = true;
const visibleRows = countVisibleComposerRows(visibleText, state.validationError !== null, shouldRenderBackground);
useEffect(() => {
onVisibleRowsChange?.(visibleRows);
}, [onVisibleRowsChange, visibleRows]);
if (isActive && composerMetrics.hasMeasured) {
setCursorPosition(
resolveComposerCursorPosition(
visibleText, inputColumns, cursorTop ?? composerMetrics.top, visiblePrompt.cursorIndex, shouldRenderBackground
)
);
} else {
setCursorPosition(undefined);
}
return (
<Box ref={composerRef} flexDirection="column">
<ComposerFrame columns={columns} shouldRenderBackground={shouldRenderBackground}
validationError={state.validationError} visibleTextRows={visibleText.split('\n')} />
</Box>
);
}
串联顺序:
- 从原子读
state(文本/光标/错误),并挂上第 9 篇的输入钩子。 - 输入可用宽度
inputColumns要扣掉PROMPT_PREFIX(>)的宽度。 - 用第 10 篇的
formatVisiblePromptView算出可见文本和可见光标索引。 - 用
countVisibleComposerRows算行数,并在useEffect里通过onVisibleRowsChange回写composerRowsAtom——闭合“输入框→状态→布局”的回路。 - 测量自身位置后,调
resolveComposerCursorPosition设置光标。 - 渲染交给
ComposerFrame。
useBoxMetrics 与“测量后才放光标”
useBoxMetrics(composerRef) 测量这个 Box 在终端里的实际位置(top、是否已测量 hasMeasured)。只有 isActive && hasMeasured 时才设置光标,否则 setCursorPosition(undefined)——避免在布局尚未稳定时把光标放到错误的位置。cursorTop 优先用外部传入的(第 4 篇 composerTopAtom 算的),没有才退回测量值 composerMetrics.top。
shouldRenderBackground 当前恒为 true。它被保留成一个变量而不是写死,是为了对应主题计划里“背景渲染应当 fallback-aware”的设计——以后接入终端能力检测时,只要让它变成条件值,所有依赖它的行数、padding、光标偏移就会一起退化到无背景模式。
光标坐标:cursorPosition.ts
export function resolveComposerCursorPosition(visibleText, columns, composerTop, cursorIndex = visibleText.length, hasBackgroundPadding = true): { x: number; y: number } {
const cursorPosition = cursorPositionForVisibleText(visibleText, columns, cursorIndex);
const topPaddingRows = hasBackgroundPadding ? COMPOSER_BACKGROUND_TOP_PADDING_ROWS : 0;
return {
x: PROMPT_PREFIX.length + cursorPosition.x,
// Ink's cursor row origin is one row below the measured box top, so the
// measured composer top plus any half-line padding lands on the editable row.
y: composerTop + topPaddingRows + cursorPosition.y + INK_CURSOR_ROW_ORIGIN_OFFSET
};
}
function cursorPositionForVisibleText(text, columns, cursorIndex): { x: number; y: number } {
const textBeforeCursor = text.slice(0, Math.max(0, Math.min(cursorIndex, text.length)));
const lines = textBeforeCursor.split('\n');
const lastLine = lines.at(-1) ?? '';
return { x: Math.min(lastLine.length, columns), y: lines.length - 1 };
}
先把可见文本里光标之前的部分切出来,按 \n 分行:行数减一就是光标的 y(第几行),最后一行的长度就是 x(第几列,夹在列宽内)。
然后叠加三个偏移把它落到终端绝对坐标:
x加PROMPT_PREFIX.length(>占两列),因为文本是从前缀之后开始画的。y加composerTop(输入框顶在第几行)+topPaddingRows(开了背景块就有 1 行上半行 padding,COMPOSER_BACKGROUND_TOP_PADDING_ROWS)+INK_CURSOR_ROW_ORIGIN_OFFSET(Ink 的光标行原点比测量到的 box top 低 1 行,必须补这 1)。
tui/AGENTS.md反复强调:光标位置是用 Ink cursor API 手动算的,一旦改了 body 高度、spacer、换行、校验行、背景行或 cwd/composer/status 的位置,就要重新确认光标落在当前文本行上。y的这三项叠加正是最容易出错的地方——少补INK_CURSOR_ROW_ORIGIN_OFFSET光标就会高一行落在 cwd 行上,多算 padding 就会低一行。
帧渲染:ComposerFrame.tsx
ComposerFrame 负责把可见文本行、可选校验错误、上下半行背景拼成最终 JSX:
export function ComposerFrame({ columns, shouldRenderBackground, validationError, visibleTextRows }) {
return (
<>
{shouldRenderBackground ? <ComposerHalfLine glyph={LOWER_HALF_BLOCK} columns={columns} /> : null}
{visibleTextRows.map((row, index) => (
<ComposerTextRow columns={columns} key={`${index}-${row}`} row={row} rowIndex={index}
shouldRenderBackground={shouldRenderBackground} />
))}
{validationError === null ? null : (
<Text backgroundColor={backgroundColor(shouldRenderBackground)} color={geminiDarkTheme.colors.errorRed}>
{formatValidationError(validationError, columns, shouldRenderBackground)}
</Text>
)}
{shouldRenderBackground ? <ComposerHalfLine glyph={UPPER_HALF_BLOCK} columns={columns} /> : null}
</>
);
}
结构自上而下:下半块过渡行(▄)→ 若干文本行 → 可选的 ERROR: 红字行 → 上半块过渡行(▀)。开背景时上下各一条半行,正好是 countVisibleComposerRows 里那 2 行 padding 的来源;校验错误存在时多一行,也和行数计算对上。
文本行:前缀与铺背景
function ComposerTextRow({ columns, row, rowIndex, shouldRenderBackground }) {
const prefix = promptPrefixForRow(rowIndex);
const rowColumns = Math.max(0, columns - prefix.length);
return (
<Box backgroundColor={backgroundColor(shouldRenderBackground)}>
<Text backgroundColor={backgroundColor(shouldRenderBackground)}
color={rowIndex === 0 ? geminiDarkTheme.colors.accentBlue : geminiDarkTheme.colors.foreground}>
{prefix}
</Text>
<Text backgroundColor={backgroundColor(shouldRenderBackground)} color={geminiDarkTheme.colors.foreground}>
{shouldRenderBackground ? row.padEnd(rowColumns, ' ') : row}
</Text>
</Box>
);
}
首行前缀是蓝色的 > (promptPrefixForRow 返回 PROMPT_PREFIX),续行用等宽空格对齐。开背景时把文本 padEnd 到列宽,让输入背景铺满整行;不开背景就只画文本本身。
半行与背景色 helper
function ComposerHalfLine({ glyph, columns }) {
return (
<Text backgroundColor={geminiDarkTheme.colors.bodyBackground} color={geminiDarkTheme.colors.inputBackground}>
{glyph.repeat(Math.max(1, columns))}
</Text>
);
}
function backgroundColor(isEnabled: boolean): string | undefined {
return isEnabled ? geminiDarkTheme.colors.inputBackground : undefined;
}
半行的着色和第 2 篇讲的一致:color 用输入背景色画实心半边、backgroundColor 用底色画另一半,于是上下边缘“缩半行”,无缝。backgroundColor helper 把“开背景用 inputBackground、不开返回 undefined”这个三元判断收敛到一处,文本行和校验行都复用它。
至此整个输入框闭环:原子存状态 → 输入钩子改状态 → 可视文本裁窗口 → 光标坐标落点 → 帧渲染。下一篇讲串起正文滚动的最后一环——鼠标解析与滚动接线。