1. U2 总览:交互式主屏
U1 阶段我们搭好了脚手架:Rust 项目、嵌套的 Ink TUI、xtask 自动化。但那时的 tui/src/App.tsx 只是一个静态空壳,渲染产品版本、当前 workspace cwd 和一行 backend-only 预览提示,用来验证 Ink 渲染链路是否打通。
U2 阶段要把这个空壳升级成一个可交互的主屏:有 KQode logo、可滚动的正文转录区、带 Git 状态的工作目录行、可输入并提交的输入框、底部状态栏,并且整个布局会随终端尺寸变化稳定地吸附在底部。这一篇先做总览,后面每一篇深入一个模块。
对应哪些计划单元
U2 这一阶段的代码同时落地了两份计划文档里的 U2 与 U3:
- 主屏计划
2026-06-25-003-feat-first-ink-tui-homepage-plan.md - 主题计划
2026-06-29-001-feat-gemini-style-tui-theming-plan.md
两个 commit 的关系
U2 的实现分成两个 commit-sized 单元:
dd15b678(feat(tui): build interactive home screen):第一次把交互式主屏搭出来。这时状态用的是一个composerReducer,背景块用的是一个独立的BackgroundBlock.tsx组件,正文行 helperbodyRows.ts放在components/下。5432e018(refactor(tui): move home screen state into Jotai):把共享状态迁移到 Jotai atoms,把过大的组件按tui/AGENTS.md的“200 行以内”约定拆成聚焦模块,把bodyRows、layout、cwdLine、backgroundBlock等纯函数 helper 移到src/libs/tui/下,并用半行 glyph 常量替代了独立的BackgroundBlock组件。
后面所有源码片段和文件链接都 pin 到第二个 commit 5432e018,也就是这两次提交叠加后的最终形态。讲解“为什么这样写”时会回顾第一个 commit 的中间形态。
数据流:从启动到渲染
入口 tui/main.tsx 负责采集运行时上下文,再把它作为 props 交给 App:
const tuiPackageRoot = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = resolveRepoRoot(tuiPackageRoot);
const workspaceCwd = resolveWorkspaceCwd();
const productVersion = readProductVersion(repoRoot);
const gitStatusLabel = readGitStatusLabel(workspaceCwd);
render(
<App screen={{ productVersion, workspaceCwd, gitStatusLabel }} />
);
这里有三类输入:
productVersion:从仓库根Cargo.toml读取的 KQode 版本(不是tui/包版本)。workspaceCwd:process.cwd(),也就是用户运行 KQode 的项目目录。gitStatusLabel:对workspaceCwd跑一次git status --porcelain解析出的分支与改动标记(第 6 篇细讲)。
tui/src/App.tsx 只做一件事:把外部 props 与终端实时尺寸合并成一份完整配置,再交给 HomeScreen:
export function App({ screen }: AppProps) {
const windowSize = useWindowSize();
const config = createHomeScreenConfig({
...screen,
columns: screen.columns ?? windowSize.columns ?? DEFAULT_COLUMNS,
rows: Math.max(MIN_ROWS, screen.rows ?? windowSize.rows ?? DEFAULT_ROWS)
});
return <HomeScreen config={config} />;
}
useWindowSize() 是 Ink 提供的 hook,终端 resize 时会触发重渲染,于是 columns/rows 跟着变,整套布局都会重新计算。Math.max(MIN_ROWS, ...) 给行数兜了个下限,避免极小终端把布局算崩。
组件树与状态边界
tui/src/components/HomeScreen/index.tsx 是状态边界:它创建一个独立的 Jotai store,把 config 写进 homeScreenConfigAtom,再用 Provider 包住真正渲染的 HomeScreenView:
export function HomeScreen({ config }: HomeScreenProps) {
const store = useHomeScreenStore(config);
return (
<Provider store={store}>
<HomeScreenView />
</Provider>
);
}
这样做的好处:HomeScreenView 及其所有子组件都直接从 atoms 读自己关心的那一小块状态,不再需要一层层往下传长长的 props 链。这正是 5432e018 这次重构的核心动机。
HomeScreenView 负责纵向排版,从上到下组合五块:
App
└─ HomeScreen (创建 Jotai store,注入 config)
└─ HomeScreenView (读 layout 原子,处理滚动按键)
├─ Header (logo + 版本,窄屏降级)
├─ HomeBody → BodyPane (正文转录 + 滚动条)
├─ HomeCwd → CwdLine (cwd + Git 状态)
├─ HomeComposer → PromptComposer(输入框)
└─ HomeStatus → StatusBar (提示 + 模型名)
每一块在 HomeScreenView 里都是一个只读若干 atom 的小包装组件(HomeHeader、HomeBody、HomeCwd、HomeComposer、HomeStatus),把 atom 值转成叶子组件的 props。叶子组件本身保持“纯展示 + 输入接线”,不直接耦合全局状态。
文件地图
U2 涉及的模块,以及本系列对应的篇目:
| 模块 | 文件 | 篇目 |
|---|---|---|
| 主题令牌 | src/theme/themeConfig.ts | 2 |
| 半行背景块 | src/libs/tui/backgroundBlock.ts | 2 |
| 主屏状态原子 | src/state/homeScreenAtoms.ts | 3、4 |
| 布局常量 | src/libs/tui/layout.ts | 4 |
| 顶部与状态栏 | src/components/Header.tsx、StatusBar.tsx | 5 |
| 工作目录与 Git | src/components/CwdLine.tsx、src/libs/tui/cwdLine.ts、src/libs/git/gitStatus.ts | 6 |
| 正文转录 | src/components/BodyPane.tsx、src/libs/tui/bodyRows.ts | 7 |
| 输入框状态 | src/state/composerAtoms.ts | 8 |
| 输入框输入 | src/components/PromptComposer/usePromptComposerInput.ts、constants.ts | 9 |
| 输入框可视文本 | src/components/PromptComposer/promptTextView.ts | 10 |
| 光标与渲染 | src/components/PromptComposer/cursorPosition.ts、ComposerFrame.tsx、index.tsx | 11 |
| 鼠标与滚动 | src/libs/terminal/mouse.ts、HomeScreenView.tsx | 12 |
接下来从最底层、被所有 UI 引用的主题令牌开始。