跳到主要内容

7. 正文转录:BodyPane 与 bodyRows

正文区是主屏的“对话记录”:它把一组 BodyEntry(信息、用户 prompt、pending、成功、错误)渲染成多行文本,支持换行、滚动和滚动条,用户 prompt 还会渲染成带半行边缘的消息块。这一篇拆两个文件:纯数据 helper bodyRows.ts 和渲染组件 BodyPane.tsx

数据模型:BodyEntry 与 BodyRow

bodyRows.ts 先定义了两层数据。BodyEntry 是“语义条目”——调用方关心的内容:

export type BodyEntry = {
id?: string;
kind: 'assistant' | 'user' | 'pending' | 'success' | 'error';
text: string;
};

BodyRow 是“渲染行”——换行、上色、加 marker 之后,真正要画到终端上的一行:

export type BodyRow = {
backgroundColor?: string;
color?: string;
fillColumns?: boolean;
marker?: string;
markerColor?: string;
text: string;
};

bodyRows.ts 的职责就是把 BodyEntry[] 编译成 BodyRow[]DEFAULT_BODY_ENTRIES 是一个空数组常量,作为缺省正文。

入口:resolveBodyRows 与滚动条让位

export function resolveBodyRows(
entries: readonly BodyEntry[],
columns: number,
visibleRows: number
): BodyRow[] {
const fullWidthRows = toBodyRowsWithEntryGaps(entries, columns);
// If content overflows vertically, reserve the final terminal column for the
// scrollbar and re-wrap text so body rows do not collide with it.
const contentColumns = fullWidthRows.length > visibleRows ? Math.max(1, columns - 1) : columns;

return contentColumns === columns
? fullWidthRows
: toBodyRowsWithEntryGaps(entries, contentColumns);
}

这里有个巧思:先按整宽换行算一遍,如果总行数超过可见行数(要出滚动条了),就把列宽减 1 重新换行,给最右边那一列留给滚动条,避免文本和滚动条字符撞在一起。没溢出时直接用整宽结果,不浪费。

countBodyRows 只是它的“只取行数”版本,被布局原子(第 4 篇)调用:

export function countBodyRows(entries, columns, visibleRows): number {
return resolveBodyRows(entries, Math.max(1, columns), Math.max(1, visibleRows)).length;
}

按类型分派:toBodyRows

function toBodyRows(entry: BodyEntry, columns: number): BodyRow[] {
if (entry.kind === 'user') {
return toPromptRows(entry.text, columns);
}

if (entry.kind === 'assistant') {
return toAssistantRows(entry.text, columns);
}

return wrapBodyText(labelForEntry(entry), columns).map((text) => ({
color: colorForEntry(entry.kind),
text
}));
}

user(用户输入)和 assistant(assistant 信息)各有专门的渲染;pending/success/error 走通用分支:先经 labelForEntry 加语义前/后缀,再换行,再上色。注意函数名叫 toBodyRowsWithEntryGaps,但实现里其实没有插入空行——条目之间不再加合成 gap 行,这符合主屏计划“正文条目之间无合成 gap 行”的要求。

颜色与标签

function colorForEntry(kind: BodyEntry['kind']): string {
switch (kind) {
case 'error': return geminiDarkTheme.colors.errorRed;
case 'pending': return geminiDarkTheme.colors.warning;
case 'success': return geminiDarkTheme.colors.accentGreen;
case 'user': return geminiDarkTheme.colors.foreground;
case 'assistant': return geminiDarkTheme.colors.muted;
}
}

function labelForEntry(entry: BodyEntry): string {
if (entry.kind === 'error') return `ERROR: ${entry.text}`;
if (entry.kind === 'pending') return `${entry.text} (pending)`;
return entry.text;
}

errorERROR: 前缀、pending (pending) 后缀——又是“非颜色语义标记”,保证去色后依然可读。

assistant 行: marker 与对齐

function toAssistantRows(text: string, columns: number): BodyRow[] {
const continuationPrefix = ' '.repeat(ASSISTANT_MESSAGE_PREFIX.length);
const wrappedText = wrapBodyText(text, Math.max(1, columns - ASSISTANT_MESSAGE_PREFIX.length));

return wrappedText.map((line, index) => ({
color: geminiDarkTheme.colors.foreground,
marker: index === 0 ? ASSISTANT_MESSAGE_PREFIX : continuationPrefix,
markerColor: index === 0 ? geminiDarkTheme.colors.accentBlue : geminiDarkTheme.colors.foreground,
text: line
}));
}

ASSISTANT_MESSAGE_PREFIX。首行用蓝色 marker,换行后的续行用等宽空格占位,让续行文本和首行对齐。换行宽度先扣掉 marker 宽度,保证加上 marker 后不超列。

用户 prompt:消息块

用户 prompt 是视觉上最重的——一个带背景色、上下半行边缘的块:

function toPromptRows(text: string, columns: number): BodyRow[] {
const promptIndent = USER_MESSAGE_HORIZONTAL_PADDING + USER_MESSAGE_PREFIX.length;
const textColumns = Math.max(1, columns - promptIndent - USER_MESSAGE_HORIZONTAL_PADDING);
const continuationPrefix = ' '.repeat(promptIndent);
const wrappedText = wrapBodyText(text, textColumns, { preserveHardLines: true });
const textRows = wrappedText.map((line, index) => ({
backgroundColor: geminiDarkTheme.colors.messageBackground,
color: geminiDarkTheme.colors.foreground,
fillColumns: true,
text: `${index === 0 ? promptPrefix() : continuationPrefix}${line}`
}));

return [
halfLineRow(columns, LOWER_HALF_BLOCK),
...textRows,
halfLineRow(columns, UPPER_HALF_BLOCK)
];
}

要点:

  • 块有左右对称的内边距(USER_MESSAGE_HORIZONTAL_PADDING = 2),文本可用宽度先扣掉缩进和右padding。
  • 首行带 前缀(USER_MESSAGE_PREFIX,含左 padding),续行用等宽空格对齐。
  • 文本行都设 backgroundColor = messageBackgroundfillColumns: true(整行铺背景,下面 BodyPane 会据此 pad 满列宽)。
  • 换行用 preserveHardLines: true,保留用户输入里的真实换行。
  • 最外层用第 2 篇讲的 halfLineRow 上下各包一条半行块( 在上、 在下),形成无缝色块。

文本换行:wrapBodyText

function wrapBodyText(text, columns, options = {}): string[] {
const wrappedRows: string[] = [];
const hardLines = options.preserveHardLines
? text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
: [fitBodyLine(text)];

for (const line of hardLines) {
if (line.length === 0) {
wrappedRows.push('');
continue;
}

for (let start = 0; start < line.length; start += columns) {
wrappedRows.push(line.slice(start, start + columns));
}
}

return wrappedRows;
}

两种模式:

  • preserveHardLines: true(用户 prompt):把 \r\n/\r 归一成 \n,按真实换行切成硬行,再逐行按列宽硬切。空行保留为 ''
  • 默认(assistant/通用):先用 fitBodyLine 把所有换行压成空格(text.replace(/[\r\n]+/g, ' ')),当作单行再按列宽切。也就是说非 prompt 内容不保留换行,避免多行 assistant 文本在 budgeting 前形态不一。

换行一律“按列宽硬切”,不做按词折行,也不加省略号——主屏计划要求“正文换行不省略、无 gap 行”。

渲染组件 BodyPane

BodyPane 拿到 BodyRow[] 后,处理可见窗口、滚动和滚动条。

可见窗口与贴底语义

const allRows = resolveBodyRows(entries, visibleColumns, visibleRows);
const maxScrollOffset = Math.max(0, allRows.length - visibleRows);
const scrollOffset = clamp(scrollOffsetRows, 0, maxScrollOffset);
// Offset counts rows back from the newest content at the bottom, matching
// terminal transcript behavior where scroll offset 0 means "stick to bottom".
const end = allRows.length - scrollOffset;
const start = Math.max(0, end - visibleRows);

scrollOffsetRows 来自状态原子,语义是“从底部往回数多少行”。end 是当前窗口的末尾(offset 0 时正好是最后一行),start 是窗口起点。于是 offset 0 永远显示最新内容,符合终端 transcript 的直觉。clamp 再保险地夹一次范围。

是否可滚动决定渲染策略

const isScrollable = maxScrollOffset > 0;
const renderedRows = isScrollable ? visibleRows : Math.min(visibleRows, allRows.length + 1);
const contentColumns = isScrollable ? Math.max(1, visibleColumns - 1) : visibleColumns;

可滚动时:固定渲染 visibleRows 行、内容列宽减 1(给滚动条)。不可滚动时:只渲染 allRows.length + 1 行(内容加一行余量),用满列宽。这个 + 1 和第 4 篇 bodyRows+ 1 呼应,保证短内容也能把底部 chrome 顶到底。

逐行渲染

{Array.from({ length: renderedRows }, (_, index) => {
const row = visibleRowsForOffset[index] ?? { color: geminiDarkTheme.colors.muted, text: '' };
const marker = row.marker ?? '';
const paddedTextColumns = Math.max(1, contentColumns - marker.length);
const shouldPadText = isScrollable || row.fillColumns === true;
const displayText = shouldPadText ? padBodyText(row.text, paddedTextColumns) : row.text || ' ';

return (
<Box key={...} backgroundColor={row.backgroundColor} width={visibleColumns}>
{marker.length > 0 ? (
<Text backgroundColor={row.backgroundColor} color={row.markerColor ?? row.color}>{marker}</Text>
) : null}
<Text backgroundColor={row.backgroundColor} color={row.color}>{displayText}</Text>
{isScrollable ? (
<Text color={scrollbarCells[index]?.color ?? geminiDarkTheme.colors.border}>
{scrollbarCells[index]?.text ?? SCROLLBAR_TRACK}
</Text>
) : null}
</Box>
);
})}

几个点:

  • 窗口内没内容的行用一个 muted 空行兜底(?? { color: muted, text: '' })。
  • shouldPadText:可滚动时或消息块行(fillColumns)会把文本 pad 满列宽——前者让滚动条对齐,后者让消息块背景铺满。普通行用 row.text || ' ' 避免空字符串行塌掉。
  • marker 和文本分两个 <Text>,因为它们可能用不同颜色(如 assistant 的蓝 配白文本)。
  • 可滚动时每行末尾追加一个滚动条格子。

滚动条:renderScrollbar

function renderScrollbar({ rows, totalRows, startRow }): ScrollbarCell[] {
// Scale the thumb to the visible fraction, then map the first visible row to
// the same fraction of the scrollbar track so top/bottom positions align.
const thumbRows = Math.max(1, Math.floor((rows / totalRows) * rows));
const maxThumbStart = rows - thumbRows;
const maxStartRow = totalRows - rows;
const thumbStart = maxStartRow === 0 ? 0 : Math.round((startRow / maxStartRow) * maxThumbStart);

return Array.from({ length: rows }, (_, index) => {
const isThumb = index >= thumbStart && index < thumbStart + thumbRows;
return {
color: isThumb ? geminiDarkTheme.colors.foreground : geminiDarkTheme.colors.border,
text: isThumb ? SCROLLBAR_THUMB : SCROLLBAR_TRACK
};
});
}

thumb 高度按“可见行 / 总行”的比例缩放(至少 1 行);thumb 位置按“当前起始行 / 最大起始行”的比例映射到轨道上。于是滚到最顶 thumb 贴顶、滚到最底 thumb 贴底,中间线性插值。track 用 border 色),thumb 用 foreground 色),同样是去色后仍可分辨的字形差异。

下一篇切到输入框这条线,先看它的状态层 composerAtoms.ts