Skip to main content

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>
);
}

串联顺序:

  1. 从原子读 state(文本/光标/错误),并挂上第 9 篇的输入钩子。
  2. 输入可用宽度 inputColumns 要扣掉 PROMPT_PREFIX> )的宽度。
  3. 用第 10 篇的 formatVisiblePromptView 算出可见文本和可见光标索引。
  4. countVisibleComposerRows 算行数,并在 useEffect 里通过 onVisibleRowsChange 回写 composerRowsAtom——闭合“输入框→状态→布局”的回路。
  5. 测量自身位置后,调 resolveComposerCursorPosition 设置光标。
  6. 渲染交给 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(第几列,夹在列宽内)。

然后叠加三个偏移把它落到终端绝对坐标:

  • xPROMPT_PREFIX.length> 占两列),因为文本是从前缀之后开始画的。
  • ycomposerTop(输入框顶在第几行)+ 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”这个三元判断收敛到一处,文本行和校验行都复用它。

至此整个输入框闭环:原子存状态 → 输入钩子改状态 → 可视文本裁窗口 → 光标坐标落点 → 帧渲染。下一篇讲串起正文滚动的最后一环——鼠标解析与滚动接线。