跳到主要内容

3. 主屏状态:配置与派生原子

这一篇深入 tui/src/state/homeScreenAtoms.ts 的状态部分:配置类型、默认值工厂、根配置原子,以及由它派生出来的正文、提交、滚动原子。这个文件里还有一大块布局计算(resolveHomeScreenLayoutlayoutAtombottomSpacerRowsAtomcomposerTopAtom),那部分留到下一篇专门讲。

为什么用 Jotai

重构 commit 5432e018 把主屏的共享状态从“一路往下传 props”改成了 Jotai atoms。原因很直接:主屏有十几个需要被多个组件读取的派生值(布局行数、滚动偏移、已提交的消息、composer 行数……),如果都用 props,叶子组件的参数列表会越来越长。Jotai 让每个组件按需订阅自己关心的那一个 atom。

配置类型:Config 与 Options

文件先定义了两个类型。HomeScreenConfig 是“完整配置”,字段都是必填:

export type HomeScreenConfig = {
productVersion: string;
workspaceCwd: string;
gitStatusLabel?: string;
modelLabel: string;
bodyEntries?: readonly BodyEntry[];
columns: number;
rows: number;
onPromptSubmit: (prompt: string) => void;
};

HomeScreenOptions 是“外部传入的可选配置”,把有默认值的字段都设为可选:

export type HomeScreenOptions = {
productVersion: string;
workspaceCwd: string;
gitStatusLabel?: string;
modelLabel?: string;
bodyEntries?: readonly BodyEntry[];
columns?: number;
rows?: number;
onPromptSubmit?: (prompt: string) => void;
};

两者的差异就是“调用方需要提供什么” vs “内部依赖什么”。main.tsx 只传了 productVersionworkspaceCwdgitStatusLabel,其余靠默认值补齐。

默认值工厂:createHomeScreenConfig

createHomeScreenConfigOptions 规整成 Config,集中处理默认值:

export const DEFAULT_MODEL_LABEL = 'GPT-5.5';
export const DEFAULT_COMPOSER_ROWS = 3;
export const BODY_CWD_GAP_ROWS = 1;

export const noopPromptSubmit = () => {};

export function createHomeScreenConfig({
productVersion,
workspaceCwd,
gitStatusLabel,
modelLabel = DEFAULT_MODEL_LABEL,
bodyEntries,
columns = DEFAULT_COLUMNS,
rows = DEFAULT_ROWS,
onPromptSubmit = noopPromptSubmit
}: HomeScreenOptions): HomeScreenConfig {
return {
productVersion,
workspaceCwd,
gitStatusLabel,
modelLabel,
bodyEntries,
columns,
rows,
onPromptSubmit
};
}

几个细节:

  • modelLabel 默认 GPT-5.5,这是状态栏右下角显示的模型名。
  • onPromptSubmit 默认是空函数 noopPromptSubmit。这样即使没有后端接线,提交也不会抛错;测试里也可以不传。
  • columns/rows 的默认值来自 layout.tsDEFAULT_COLUMNS(80)和 DEFAULT_ROWS(24)。注意 App.tsx 实际上已经用终端真实尺寸覆盖了它们,这里的默认值主要服务于测试和无尺寸场景。

BODY_CWD_GAP_ROWS = 1 是“正文区与 cwd 行之间恰好一个空行”的硬约定,对应 tui/AGENTS.md 里“body 与 cwd 之间留且仅留一个空行”的要求。

根配置原子

homeScreenConfigAtom 是整棵状态树的根。它有一个占位初值,真正的值由 HomeScreen 组件在创建 store 时写入(见第 1 篇):

export const homeScreenConfigAtom = atom<HomeScreenConfig>({
productVersion: '',
workspaceCwd: '',
modelLabel: DEFAULT_MODEL_LABEL,
columns: DEFAULT_COLUMNS,
rows: DEFAULT_ROWS,
onPromptSubmit: noopPromptSubmit
});

下面所有派生原子都从它(以及少数几个可写原子)get 出来。

已提交消息与正文派生

输入框提交的内容不会直接塞进 config.bodyEntries(那是只读的初始正文),而是单独累积在一个可写原子里:

export const submittedPromptEntriesAtom = atom<BodyEntry[]>([]);

displayedBodyEntriesAtom 把“初始正文”和“已提交消息”拼成最终要渲染的列表:

export const displayedBodyEntriesAtom = atom((get) => {
const config = get(homeScreenConfigAtom);
const submittedPromptEntries = get(submittedPromptEntriesAtom);
const baseBodyEntries = config.bodyEntries ?? DEFAULT_BODY_ENTRIES;

return submittedPromptEntries.length === 0
? config.bodyEntries ?? DEFAULT_BODY_ENTRIES
: [...baseBodyEntries, ...submittedPromptEntries];
});

这里特意区分了两种情况:没有任何提交时,直接返回 config.bodyEntries(保持原引用,避免无谓的新数组导致重渲染);有提交时才合并。DEFAULT_BODY_ENTRIES 是一个空数组常量(见第 7 篇),作为缺省正文。

提交动作:submitPromptAtom

submitPromptAtom 是一个只写原子(read 部分为 null),封装“提交一条 prompt”这个动作:

export const submitPromptAtom = atom(null, (get, set, prompt: string) => {
const config = get(homeScreenConfigAtom);
set(submittedPromptEntriesAtom, (current) => [...current, { kind: 'user', text: prompt }]);
set(bodyScrollOffsetRowsAtom, 0);
config.onPromptSubmit(prompt);
});

它做三件事,顺序很重要:

  1. 把这条 prompt 作为一个 { kind: 'user', text } 条目追加到已提交列表,于是它会出现在正文里(渲染成用户消息块)。
  2. 把滚动偏移重置为 0,也就是“贴回最新内容”——提交后用户总是希望看到自己刚发的消息。
  3. 调用 config.onPromptSubmit(prompt),把 prompt 交给外部(未来的后端队列)。当前默认是 noop。

HomeScreenView 里的 HomeComposer 把这个原子的 setter 作为 onSubmit 传给 PromptComposer,输入框回车提交时就会触发它。

滚动状态

滚动偏移本身是一个可写原子:

export const bodyScrollOffsetRowsAtom = atom(0);

它的语义是“从最新内容往回数多少行”:0 表示贴底(显示最新输出),数值越大表示往上翻越多。配套的写原子 scrollBodyByRowsAtom 负责按增量滚动并夹在合法区间内:

export const scrollBodyByRowsAtom = atom(null, (get, set, deltaRows: number) => {
const maxBodyScrollOffsetRows = get(maxBodyScrollOffsetRowsAtom);
set(bodyScrollOffsetRowsAtom, (current) =>
Math.min(maxBodyScrollOffsetRows, Math.max(0, current + deltaRows))
);
});

Math.max(0, ...) 保证不会翻过最新内容,Math.min(maxBodyScrollOffsetRows, ...) 保证不会翻过最旧内容。maxBodyScrollOffsetRowsAtom 依赖布局结果,放到下一篇讲。鼠标滚轮、PageUp/PageDown 最终都汇聚到 scrollBodyByRowsAtom(第 12 篇)。

另外还有一个 composerRowsAtom

export const composerRowsAtom = atom(DEFAULT_COMPOSER_ROWS);

它记录输入框当前实际占用的可见行数。输入框自己测量后通过 onVisibleRowsChange 回写这个原子,布局再据此给正文和底部留位。这条“输入框→状态→布局”的回路,下一篇会和布局算法一起串起来。