Skip to main content

4. 布局算法:底部吸附与行预算

这一篇讲 U2 里最“数学”的部分:怎么在任意终端尺寸下,把 cwd 行、输入框、状态栏稳定地吸附在终端底部,正文区占据剩下的空间,并且正文与 cwd 之间永远只留一个空行。涉及 layout.ts 的常量与 headerRowCount,以及 homeScreenAtoms.ts 里的 resolveHomeScreenLayoutlayoutAtombottomSpacerRowsAtomcomposerTopAtommaxBodyScrollOffsetRowsAtom

布局常量与 Header 行数

layout.ts 是一组宽度阈值常量加一个函数:

export const DEFAULT_COLUMNS = 80;
export const DEFAULT_ROWS = 24;
export const MIN_ROWS = 10;
export const HIDE_HEADER_BELOW_COLUMNS = 36;
export const COMPACT_HEADER_BELOW_COLUMNS = 52;
export const DEFAULT_COMPOSER_VISIBLE_LINES = 3;

export function headerRowCount(columns: number): number {
if (columns < HIDE_HEADER_BELOW_COLUMNS) {
return 0;
}

if (columns < COMPACT_HEADER_BELOW_COLUMNS) {
return 1;
}

return 1;
}

headerRowCount 回答“当前列宽下,header 占几行”。它把列宽分成三档:小于 36 列时 header 完全隐藏(0 行),36–52 列时显示紧凑版(1 行),更宽时显示完整版(1 行)。后两档目前都返回 1,写成两个分支是为了让“紧凑”和“完整”这两个产品状态显式存在——以后如果完整版变成多行 logo,只改后一个分支即可,紧凑档不受影响。布局计算只关心“占几行”,所以它依赖 headerRowCount 而不是直接判断列宽。

核心:resolveHomeScreenLayout

这是布局的纯函数核心,输入终端尺寸和几个内容行数,输出三块的行数:

export function resolveHomeScreenLayout(
columns: number,
rows: number,
bodyEntryCount = Number.POSITIVE_INFINITY,
composerRows = DEFAULT_COMPOSER_ROWS,
cwdRows = 1
): { bodyRows: number; composerVisibleRows: number; cwdRows: number } {
const headerRows = headerRowCount(columns);
const resolvedCwdRows = Math.max(1, cwdRows);
const statusRows = 1;
const composerErrorReserveRows = COMPOSER_ERROR_RESERVE_ROWS;
const minBodyRows = 1;
// Fixed rows exclude the composer because it grows with wrapping/validation;
// reserving one possible error row keeps the body from collapsing below 1 row.
const fixedRows = headerRows + BODY_CWD_GAP_ROWS + resolvedCwdRows + statusRows;
const maxComposerVisibleRows = Math.max(
1,
rows - fixedRows - COMPOSER_BACKGROUND_PADDING_ROWS - composerErrorReserveRows - minBodyRows
);
const maxBodyRows = rows - fixedRows - composerRows;

return {
bodyRows: Math.max(1, Math.min(maxBodyRows, bodyEntryCount + 1)),
composerVisibleRows: maxComposerVisibleRows,
cwdRows: resolvedCwdRows
};
}

逐段拆解:

  • 固定行 fixedRows:header + 一个 body/cwd 空行 + cwd 行数 + 状态栏 1 行。注意它不含输入框,因为输入框会随换行/校验反馈伸缩,不能算进固定部分。
  • 正文上限 maxBodyRows:总行数减掉固定行,再减掉当前输入框行数,剩下都能给正文。
  • 正文实际行数 bodyRows:取 maxBodyRowsbodyEntryCount + 1 的较小值,再兜底到至少 1 行。+ 1 是给“贴底”留一行余量,使内容不足时底部 chrome 不会被顶上去(配合下面的 spacer)。bodyEntryCount 默认 +∞,意味着不传内容时按“尽量占满”算。
  • 输入框可见上限 composerVisibleRows:从总行里扣掉固定行、背景块的上下半行 padding(COMPOSER_BACKGROUND_PADDING_ROWS = 2)、预留的一行校验错误(COMPOSER_ERROR_RESERVE_ROWS = 1)、以及至少 1 行正文,剩下的就是输入框最多能涨到几行。这给输入框设了天花板,避免超长 prompt 把 cwd/状态栏顶出屏幕。

这个函数是纯的、可单测的,把“尺寸 → 行数分配”这件容易出错的事隔离出来。

layoutAtom:把内容量喂进布局

layoutAtom 是派生原子,负责把当前的真实内容量算成行数,再调用 resolveHomeScreenLayout

export const layoutAtom = atom((get) => {
const config = get(homeScreenConfigAtom);
const composerRows = get(composerRowsAtom);
const displayedBodyEntries = get(displayedBodyEntriesAtom);
const bodyEntryRows = countBodyRows(displayedBodyEntries, config.columns, config.rows);

return resolveHomeScreenLayout(
config.columns,
config.rows,
bodyEntryRows,
composerRows,
countCwdRows(config.workspaceCwd, config.gitStatusLabel, config.columns)
);
});

它把三类“真实内容行数”算出来喂进去:

  • bodyEntryRows:正文条目换行后的总行数,来自 countBodyRows(第 7 篇)。
  • composerRows:输入框当前回写的实际行数(第 3 篇的 composerRowsAtom)。
  • cwd 行数:来自 countCwdRows(第 6 篇),长 cwd 会软换行成多行。

因为 layoutAtom 订阅了 composerRowsAtomdisplayedBodyEntriesAtom,所以输入框一长高、或正文一变多,布局立刻重算,这就是动态行移位的来源。

底部吸附:bottomSpacerRowsAtom

光算出各块行数还不够——内容不足时,怎么让 cwd/输入框/状态栏“沉”到底部?答案是在正文和 cwd 之间插一个可变高度的 spacer,把所有富余行都塞给它:

export const bottomSpacerRowsAtom = atom((get) => {
const config = get(homeScreenConfigAtom);
const layout = get(layoutAtom);
const composerRows = get(composerRowsAtom);
// Keep cwd/composer/status pinned to the bottom by giving every spare row to
// the body-to-cwd spacer instead of allowing the body to push the prompt down.
return Math.max(
0,
config.rows -
headerRowCount(config.columns) -
layout.bodyRows -
BODY_CWD_GAP_ROWS -
layout.cwdRows -
composerRows -
1
);
});

它本质是“总行数减去所有已知块(header + 正文 + 那一个固定空行 + cwd + 输入框 + 状态栏 1 行)”剩下的余量。HomeScreenView 把这个 spacer 加在 cwd 行的 marginTop 上:

<Box marginTop={bottomSpacerRows + BODY_CWD_GAP_ROWS}>
<CwdLine ... />
</Box>

于是无论正文多短,cwd 行以下的部分都被推到终端底部;正文一旦变多,spacer 缩到 0,布局又自然回到“正文占满”的状态。这正是 tui/AGENTS.md 要求的“底部吸附 + body/cwd 之间恰好一个空行”。

光标定位:composerTopAtom

输入框的光标位置是用 Ink 的 cursor API 手动算的,需要知道输入框第一行文本在第几行(0 基):

export const composerTopAtom = atom((get) => {
const config = get(homeScreenConfigAtom);
const composerRows = get(composerRowsAtom);
// `rows` is a count while Ink cursor coordinates are zero-based; subtract the
// status row plus the composer height to get the first composer text row.
return config.rows - 1 - composerRows;
});

config.rows 是“行数”(从 1 数),而光标坐标是从 0 数的,所以减 1 得到最后一行的索引;再减 composerRows,得到输入框区域顶部的行索引。HomeComposer 把它作为 cursorTop 传给 PromptComposer,光标计算细节见第 11 篇。

tui/AGENTS.md 特别提醒:只要改了 body 高度、spacer、换行、校验行、背景行或 cwd/composer/status 的位置,就要重新验证光标仍落在输入框当前文本行上,而不是上一行或 cwd 行。composerTopAtom 正是这条链路的关键一环。

滚动上限:maxBodyScrollOffsetRowsAtom

最后补上第 3 篇欠的那块——滚动上限。它需要知道“正文总行数”和“正文可见行数”的差:

export const maxBodyScrollOffsetRowsAtom = atom((get) => {
const config = get(homeScreenConfigAtom);
const layout = get(layoutAtom);
const bodyRowsForScroll = countBodyRows(get(displayedBodyEntriesAtom), config.columns, layout.bodyRows);

return Math.max(0, bodyRowsForScroll - layout.bodyRows);
});

注意这里第三个参数传的是 layout.bodyRows(可见行数),而不是 config.rows。这很关键:countBodyRows 在内容溢出时会预留滚动条那一列并据此重新换行(第 7 篇),所以必须用真正的可见高度去判断是否溢出,算出的总行数才准确。maxBodyScrollOffsetRowsAtom 再被 scrollBodyByRowsAtom(第 3 篇)用来夹住滚动范围。

至此,状态层和布局层就闭环了:内容量 → layoutAtom 分配行数 → spacer 吸底、composerTop 定光标、maxScroll 限滚动。下一篇回到看得见的 UI,从最简单的 Header 和 StatusBar 开始。