跳到主要内容

1. U2 总览:交互式主屏

U1 阶段我们搭好了脚手架:Rust 项目、嵌套的 Ink TUI、xtask 自动化。但那时的 tui/src/App.tsx 只是一个静态空壳,渲染产品版本、当前 workspace cwd 和一行 backend-only 预览提示,用来验证 Ink 渲染链路是否打通。

U2 阶段要把这个空壳升级成一个可交互的主屏:有 KQode logo、可滚动的正文转录区、带 Git 状态的工作目录行、可输入并提交的输入框、底部状态栏,并且整个布局会随终端尺寸变化稳定地吸附在底部。这一篇先做总览,后面每一篇深入一个模块。

对应哪些计划单元

U2 这一阶段的代码同时落地了两份计划文档里的 U2 与 U3:

两个 commit 的关系

U2 的实现分成两个 commit-sized 单元:

  • dd15b678(feat(tui): build interactive home screen):第一次把交互式主屏搭出来。这时状态用的是一个 composerReducer,背景块用的是一个独立的 BackgroundBlock.tsx 组件,正文行 helper bodyRows.ts 放在 components/ 下。
  • 5432e018(refactor(tui): move home screen state into Jotai):把共享状态迁移到 Jotai atoms,把过大的组件按 tui/AGENTS.md 的“200 行以内”约定拆成聚焦模块,把 bodyRowslayoutcwdLinebackgroundBlock 等纯函数 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/ 包版本)。
  • workspaceCwdprocess.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 的小包装组件(HomeHeaderHomeBodyHomeCwdHomeComposerHomeStatus),把 atom 值转成叶子组件的 props。叶子组件本身保持“纯展示 + 输入接线”,不直接耦合全局状态。

文件地图

U2 涉及的模块,以及本系列对应的篇目:

模块文件篇目
主题令牌src/theme/themeConfig.ts2
半行背景块src/libs/tui/backgroundBlock.ts2
主屏状态原子src/state/homeScreenAtoms.ts3、4
布局常量src/libs/tui/layout.ts4
顶部与状态栏src/components/Header.tsxStatusBar.tsx5
工作目录与 Gitsrc/components/CwdLine.tsxsrc/libs/tui/cwdLine.tssrc/libs/git/gitStatus.ts6
正文转录src/components/BodyPane.tsxsrc/libs/tui/bodyRows.ts7
输入框状态src/state/composerAtoms.ts8
输入框输入src/components/PromptComposer/usePromptComposerInput.tsconstants.ts9
输入框可视文本src/components/PromptComposer/promptTextView.ts10
光标与渲染src/components/PromptComposer/cursorPosition.tsComposerFrame.tsxindex.tsx11
鼠标与滚动src/libs/terminal/mouse.tsHomeScreenView.tsx12

接下来从最底层、被所有 UI 引用的主题令牌开始。