diff --git a/.agents/skills/write-tui/DESIGN.md b/.agents/skills/write-tui/DESIGN.md new file mode 100644 index 000000000..61c16615d --- /dev/null +++ b/.agents/skills/write-tui/DESIGN.md @@ -0,0 +1,178 @@ +# TUI 设计规范(Design Spec) + +> 本目录所有 dialog / selector / 输入框的**单一真值源**。新增或改造交互组件前先读本文件,提交前对照文末「自查清单」。 +> 基准组件:`components/dialogs/model-selector.ts`(`/model`)。所有列表型 dialog 的头部、hint、搜索、选中/当前态都以它为准对齐。 + +--- + +## 1. 视觉状态 + +| 语义 | 规范 | 常量 / token | +|---|---|---| +| 选中项指针 | `❯ `(`primary`) | `constant/symbols.ts` → `SELECT_POINTER` | +| 选中项文字 | `primary` + bold | `chalk.hex(colors.primary).bold` | +| 当前 / 激活项 | 行尾 ` ← current`(`success`) | `constant/symbols.ts` → `CURRENT_MARK` | +| 危险项 / 操作 | `error`(选中再加 bold) | `chalk.hex(colors.error)` | +| 危险确认 `[y/N]` | `warning` + bold | `chalk.hex(colors.warning)` | +| 开关项状态:开 | 名称后 ` enabled`(`success`) | `chalk.hex(colors.success)` | +| 开关项状态:关 | 名称后 ` disabled`(`textDim`) | `chalk.hex(colors.textDim)` | +| 列表 / 选择器边框 | 平直 `─`(`primary`),仅顶/底各一条 | — | +| 输入框边框 | 圆角 `╭ ╮ ╰ ╯`(`primary`) | — | + +- **不要**自造选中指针(`>` / `▶` / `→` 等);统一用 `SELECT_POINTER`。 +- **不要**用 `● ` / `(current)` 表示当前项;统一用 `CURRENT_MARK`(行尾、`success`、前置一个空格)。 +- 当前项与选中项**互相独立**:当前项是「现在生效的值」(行尾 marker),选中项是「光标所在行」(指针 + 高亮);两者可同时落在同一行。 + +## 2. 颜色 + +- 一律使用**语义 token**:`chalk.hex(colors.)`。仓库 `chalk-named-color-guard` 已强制此约定,**禁止** `chalk.red` / `chalk.gray` 等 named color。 +- `ThemeStyles`(`state.theme.styles.*()`)是可选的便捷封装;用与不用都可,但颜色必须来自 `ColorPalette` token。 +- 可用语义 token 见 `theme/colors.ts`:`primary` `accent` `text` `textStrong` `textDim` `textMuted` `border` `borderFocus` `success` `warning` `error` `status` … +- **hint 行不做键位高亮**:整行 `textMuted`,不给 `Enter` / `Esc` / `D` 等键位单独上色。 + +## 3. 列表 dialog 标准布局 + +以 `model-selector` 为准,自上而下逐行固定为: + +``` +───────────────────────────────────────── ① 顶部边框(primary,整宽 ─) + Select a model (type to search) ② 标题(primary+bold)+ 可搜索且无 query 时的后缀(textMuted) + ↑↓ navigate · Enter select · Esc cancel ③ hint(textMuted,紧贴标题,无键位高亮) + ④ 空行 + Search: gpt ⑤ 搜索行:仅在有 query 时出现(` Search: ` primary + query text) + ❯ GPT-5 openai ⑥ 列表项:指针 + 名称(左)+ 次要列(右,textMuted) + Kimi K2 Kimi Code ← current 当前项行尾 ` ← current`(success) + ⑦ 空行 + ▼ 3 more ⑧ 滚动 / 匹配指示:无 query 时 `▼ N more`,有 query 时 `x / y` +───────────────────────────────────────── ⑨ 底部边框(primary,整宽 ─) +``` + +硬性约定: + +- **头部只有顶部一条 `─`**。标题下方紧跟 hint,**不得**再插一条 `─`。整个 dialog 全宽 `─` 仅 2 条(顶 + 底)。 +- **`(type to search)` 只出现在标题后缀**(可搜索且 query 为空时);hint 行**不再**重复出现「type to search」。 +- **`Search:` 行在空行之下、列表之上**,只在有 query 时渲染。 +- hint 紧贴标题(中间无空行);hint 与正文之间有 1 空行。 +- 每行最终经 `truncateToWidth(line, width)`,CJK / 窄终端不超宽。 + +## 4. hint 行与文案词汇(英文 UI) + +每段 hint 形如「**键位 + 描述**」,段间用 ` · `(单空格中点)分隔。 + +| 动作 | 键位 token | 描述词 | 完整片段 | +|---|---|---|---| +| 移动 | `↑↓` | navigate | `↑↓ navigate` | +| 翻页 | `←→` 或 `PgUp/PgDn` | page | `←→ page` | +| 确认 / 选中 | `Enter` | select | `Enter select` | +| 取消 / 关闭 | `Esc` | cancel | `Esc cancel` | +| 删除 | `D` | delete | `D delete` | +| 清空搜索 | `Backspace` | clear | `Backspace clear` | +| 切 provider | `Tab` | toggle provider | `Tab toggle provider` | +| 搜索(标题后缀) | 打字 | — | `(type to search)` | + +- **键位 token 首字母大写**(`Enter` / `Esc` / `Tab` / `Backspace` / `D`),**描述词全小写**(navigate / select / cancel / page / delete / clear);方向符 `↑↓` / `←→` 原样。 +- 方向符统一 `↑↓`(不用 `▲/▼`)。 +- 「离开对话框」统一只说 `cancel`(不混用 close / back / exit / dismiss)。业务语义(如审批的 reject)例外。 +- hint 随状态精简:可搜索列表无 query 时,「type to search」在标题后缀已出现,hint 不重复;有 query 时 hint 追加 `Backspace clear`。 + +## 5. Tab 条(`/model` 的 provider 切换) + +`tabbed-model-selector` 在 flat `model-selector` 外包一层 provider tab,样式对齐 **AskUserQuestion** 的 tab: + +``` + Select a model (type to search) + Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint 首项即 Tab 切换 + ← 空行 + All Kimi Code openai ← tab 条:激活项填充背景(primary 底 + text 字 + bold),其余 textMuted + ← 空行 + ❯ ... +``` + +- tab 条位置:**在 hint 行下方**,且**上下各一空行**(与 hint、与列表都隔开)。 +- 激活 tab:`chalk.bgHex(colors.primary).hex(colors.text).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。 +- 第一个 tab 恒为 `All`(聚合所有 provider);**默认停在 `All`**。仅当显式传 `initialTabId`(如 `/provider` 新增完跳转)才停在指定 provider tab。 +- `Tab` / `Shift+Tab` 循环切换;hint 行首项即 `Tab toggle provider`。 +- 当前模型在所在 tab 内仍以 `❯` + ` ← current` 标记,切 tab 不丢失定位。 + +## 6. 键位 + +| 动作 | 键 | 判定方式 | +|---|---|---| +| 移动 | `↑` / `↓` | `matchesKey(data, Key.up/down)` | +| 翻页 | `PgUp` / `PgDn` | `matchesKey(data, Key.pageUp/pageDown)` | +| 确认 / 选中 | `Enter` | `matchesKey(data, Key.enter)` | +| 取消 / 关闭 | `Esc` | `matchesKey(data, Key.escape)` | +| 删除 | `D` | `printableChar(data) === 'D'`(也接受 `'d'`) | +| 搜索 | 打字 | `printableChar(data)` | + +- **字符比较必须经 `printableChar()`**(Kitty 协议),由 `printable-key-guard` 强制;功能键用 `matchesKey(data, Key.*)`。 +- **`Esc` 两段式**:有 query 时先清空 query(`list.clearQuery()`),无 query 时才 `onCancel()`。 +- `←` / `→` 不固定语义:无翻页结构的组件里承担「值切换」(如 `/model` 的 thinking on/off);`choice-picker` 这类无横向值的列表里用作翻页。**不要**在有 thinking 切换的组件里又拿 `←→` 翻页。 +- **删除键统一用字母 `D`**(`/provider`、`/plugins` 一致)。字母键要求该列表**不可 type-to-search**(否则会打进搜索框)——当前所有带删除动作的列表都不可搜索;若某列表既要搜索又要删除,删除须改用非打印键。 + +## 7. 开关列表与多选(toggle / multi-select) + +适用于「每行可独立开 / 关」的列表(如 `/plugins` 的已装插件、MCP server 列表)。区别于单选(`Enter` 选中即提交并关闭),开关列表用 `Space` 就地切换每行状态,dialog 不关闭。 + +``` + Plugins + ↑↓ navigate · Space toggle · Enter details · Esc cancel + ← 空行 + Installed plugins (2) ← 分区标题(textStrong / 加粗) + ❯ Kimi Datasource enabled ← 选中行(❯ + primary+bold 名称)+ 状态标签(success) + id kimi-datasource · 1 skill · MCP 1/1 · via code.kimi.com · official ← 次要信息行(textMuted,` · ` 分隔) + Superpowers disabled ← 未选中行(text 名称)+ 关态标签(textDim) + id superpowers · 14 skills · via code.kimi.com · curated +``` + +约定: + +- **`Space` 切换当前行状态**(开 ↔ 关),即时生效、dialog 保持打开;hint 含 `Space toggle`。 +- **状态标签**紧跟名称、空 2 格:开 ` enabled`(`success`)、关 ` disabled`(`textDim`)。其它语义(如 `installed`=success、`install…`=primary)按 `statusStyle` 同源处理。 +- `Enter` 在开关列表里另作他用(如「查看详情」`Enter details`),不承担 toggle。 +- 多套独立动作时(toggle / 详情 / 删除 / 进子菜单),hint 逐项列全,键位首字母大写:`Space toggle · Enter details · D remove`(参照第 4 节大小写规则)。 +- 行下可附 1 行次要信息(id / 数量 / 来源 / 信任级),`textMuted`、` · ` 分隔。 + +## 8. Thinking 控件(`/model` 专属) + +列表下方展示当前选中模型的 thinking 三态,外观固定 `[ On ] Off` 段式: + +- 标题:`Thinking (←→ to switch)`(仅 `toggle` 态显示括号提示);其余态只显示 `Thinking`。 +- `toggle`:`[ On ] Off` / `On [ Off ]`,激活段 `primary+bold`。 +- `always-on`:`[ Always on ]`。 +- `unsupported`:`[ Off ]` + `unsupported`(textMuted)。 +- `←` / `→` 翻转草稿;提交时经 `effectiveThinking()` 归一(always-on→true、unsupported→false)。 + +## 9. 输入框(多字段) + +- 圆角盒 `╭ ╮ ╰ ╯`(`primary`)。 +- 字段切换:`Tab` / `Shift+Tab` / `↑` / `↓`。 +- `Enter`:非末段→推进到下一字段;末段→提交。 +- 取消:`Esc` / `Ctrl+C` / `Ctrl+D`。 +- footer 随焦点动态:非末段显示 `Enter next`,末段显示 `Enter submit`。 +- 必填校验按字段顺序定位(如 custom-registry:URL 空→定位 URL,token 空→定位 token),错误用对应的子提示态。 + +## 10. 共享组件(优先复用,不另起炉灶) + +| 形态 | 组件 | +|---|---| +| 列表光标 / 搜索 / 翻页状态机 | `utils/searchable-list.ts` → `SearchableList` | +| 分页视图 | `utils/paging.ts` → `pageView` | +| Kitty 可打印字符 | `utils/printable-key.ts` → `printableChar` / `isPrintableChar`(含 guard) | +| 选中指针 / 当前项标记 | `constant/symbols.ts` → `SELECT_POINTER` / `CURRENT_MARK` | + +新列表组件**必须复用 `SearchableList`**(光标 / 搜索 / 翻页),并手工对齐本文件第 3–8 节的布局、键位、文案。 + +## 11. 新增 / 改造 dialog 自查清单 + +- [ ] 头部按第 3 节:顶部一条 `─`、标题(+`(type to search)` 后缀)、hint、空行、`Search:` 行、列表、底部一条 `─`;标题下**无**内层 `─`。 +- [ ] hint 整行 `textMuted`,**不**做键位高亮;键位首字母大写、描述词小写、` · ` 分隔。 +- [ ] 选中指针用 `SELECT_POINTER`,当前项用 `CURRENT_MARK`,未自造 `>` / `▶` / `→` / `● ` / `(current)`。 +- [ ] 颜色全部来自 `colors.`,无 named color。 +- [ ] 键位:`↑↓` 移动、`PgUp/PgDn` 翻页、`Enter` 确认、`Esc` 取消(可搜索列表 `Esc` 两段式:先清 query 再关闭)、`D` 删除;字符比较经 `printableChar()`。 +- [ ] 「离开对话框」只说 `cancel`,不混用 close / back / exit / dismiss。 +- [ ] 开关列表用 `Space toggle` 就地切换、不关闭;状态标签 ` enabled`(`success`) / ` disabled`(`textDim`) 紧跟名称空 2 格(见第 7 节)。 +- [ ] 长列表有滚动 / 翻页指示(`▼ N more` 或 `x / y`),空态文案明确(`No matches` 等)。 +- [ ] 每行经 `truncateToWidth(line, width)`,CJK / 窄终端下不超宽。 +- [ ] 复用 `SearchableList`;输入框圆角盒,多字段支持 `Tab/↑↓` 切换、Enter 推进 / 末段提交。 +- [ ] 有对应的组件测试(render 快照 + handleInput 键行为)。 diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md new file mode 100644 index 000000000..be3885a31 --- /dev/null +++ b/.agents/skills/write-tui/SKILL.md @@ -0,0 +1,83 @@ +--- +name: write-tui +description: Use when writing or modifying the kimi-code terminal UI in apps/kimi-code/src/tui — components, dialogs/selectors, slash commands, themes, streaming render, or the KimiTUI controllers. Covers the architecture, where new features go, test placement, the theme system mechanics, and the dialog interaction/visual spec (DESIGN.md). +--- + +# Write TUI (apps/kimi-code) + +The terminal UI lives in `apps/kimi-code/src/tui`. Before writing TUI code, read `apps/kimi-code/AGENTS.md` for the always-on **map, module boundaries, and hard constraints** (printable-key decoding, no chalk named colors, etc.). This skill is the **how-to**: architecture orientation, feature routing, test placement, theme mechanics, and the dialog spec. + +For any list dialog, selector, input box, or status/toggle list, the interaction and visual rules are normative — see **[DESIGN.md](./DESIGN.md)** in this folder and follow its self-check list before submitting. + +## Architecture + +`KimiTUI` is a **coordinator** that wires state, layout, session, and dialogs together and delegates heavy logic to controllers. + +- `src/tui/kimi-tui.ts` — the `KimiTUI` coordinator. Holds `state`, owns startup/shutdown order, layout/editor wiring, user-input entry, sending/queueing, session lifecycle, and the slash-command handler dispatch. It should **not** accumulate event-routing or rendering logic — those live in controllers. +- `src/tui/tui-state.ts` — `TUIState`, `createTUIState`, `createInitialAppState`. The single global UI state shape. Before adding a new global field, decide whether it truly belongs here vs. local component state. +- `src/tui/controllers/` — the independently-testable responsibilities. Each controller owns one slice: + - `session-event-handler.ts` — routes SDK session events (`handleEvent` dispatch + the per-event `handleXxx`). Concrete event handling goes here, not in `KimiTUI`. + - `streaming-ui.ts` — streaming render: assistant delta, thinking, tool call / result, compaction, subagent, background agent, transcript aggregation. + - `session-replay.ts` — resume/replay orchestration; drives replay records through the same live render hooks. Stateless replay parsing/limiting/projection helpers belong in `src/tui/utils/message-replay.ts`. + - `tasks-browser.ts` — the tasks browser controller. + - `editor-keyboard.ts` — editor keyboard handling, exit shortcuts, external editor, clipboard image. + - `auth-flow.ts` — login/auth orchestration (`refreshConfigAfterLogin`, etc.). +- `src/tui/commands/` — slash-command declaration, parsing, ordering, and dynamic skill-command generation. Parsing and types only; execution is dispatched from `KimiTUI`'s slash-command handler section, and complex execution sinks into `utils` or focused components. +- `src/tui/components/` — pi-tui components by UI type: `chrome/` (footer, todo, welcome, loader, device code), `dialogs/` (selectors, approval/question panels, settings popups that replace the editor), `editor/` (input box + mention provider), `media/` (image, diff, code highlight), `messages/` (transcript blocks + tool-renderers), `panes/` (activity, queue). +- `src/tui/reverse-rpc/` — adapts SDK approval/question callbacks into UI panel data and the user's choice back into an SDK response. +- `src/tui/theme/` — themes, color tokens, style helpers, pi-tui markdown theme, terminal-background detection. The single source of truth for color. +- `src/tui/utils/` — TUI-only utilities (need `TUIState` or a component). App-wide, UI-independent helpers go in `src/utils/`. + +When a controller or `KimiTUI` section keeps growing, split pure functions, state projections, and presentation components into the matching directory rather than expanding the file. + +## Where new features go + +The feature type decides the landing spot: + +- **CLI arguments** → `src/cli/commands.ts` / `src/cli/options.ts`, passed into the TUI via `src/cli/run-shell.ts`. The CLI never operates on the session directly. +- **CLI subcommands** → `src/cli/sub/`, non-interactive only; reach core via `@moonshot-ai/kimi-code-sdk`. +- **Slash commands** → declare/parse/type under `src/tui/commands/`; add the execution entry in `KimiTUI`'s slash-command handler section; sink complex logic into `utils` or a focused component. +- **Skill-derived commands** → hook into `buildSkillSlashCommands` / the skill command map; do not hard-code a single skill. +- **Transcript message types** → define the shape in `src/tui/types.ts`, add/extend a `components/messages/` component, register the renderer in the transcript builder. +- **Tool-result display** → extend `components/messages/tool-renderers/registry.ts` and the renderer; do not stack branches inside `ToolCallComponent`. +- **Popup / selector** → `components/dialogs/`, mounted via `mountEditorReplacement`; follow [DESIGN.md](./DESIGN.md). If triggered by an SDK callback, check whether `reverse-rpc/` needs an adapter/controller/handler. +- **SDK event handling** → add the dispatch in `session-event-handler.ts`'s `handleEvent`, then the matching `handleXxx`. +- **Streaming render** → `controllers/streaming-ui.ts`. +- **Session start / resume behavior** → the session-management section of `KimiTUI`; replay behavior → `controllers/session-replay.ts`, reusing live render paths. +- **Status bar / activity / queue** → `chrome/footer`, `panes/activity`, `panes/queue`, and the matching `updateXxx`. +- **Configuration option** → read/write + schema in `src/tui/config.ts`, then the settings UI; persist through `saveTuiConfig` (a component never writes the config file itself). +- **Constants** → shared CLI/TUI non-copy constants in `src/constant/`; TUI-only non-copy constants in `src/tui/constant/`. Component-local copy, option labels, help text, dialog titles/footers stay next to their component — do not centralize copy into a global module. +- **General capability** → no TUI-state dependency → `src/utils/`; depends on TUI state or a component → `src/tui/utils/`. + +## Test placement + +- Component behavior tests sit next to the component's existing tests (`test/tui/components/...`). +- Command parsing tests → `test/tui/commands/`. +- reverse-rpc tests → `test/tui/reverse-rpc/`. +- Pure utility tests → next to the corresponding utils tests. +- Do not create a generic `some-feature.test.ts` just to land a small feature; extend the nearest existing test file. + +## Theme system mechanics + +Themes are managed centrally under `src/tui/theme/`: + +- `colors.ts` — semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. +- `styles.ts` — common chalk helpers built on top of `ColorPalette`. +- `pi-tui-theme.ts` — the markdown/pi-tui theme config. +- `terminal-background.ts` — terminal background detection used by auto resolution. +- `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`. +- `index.ts` / `detect.ts` — theme type and auto/dark/light resolution. + +Apply / switch flow: + +- UI entry: `ThemeSelectorComponent` → `handleThemeCommand` → `applyThemeChoice`. +- The real apply step is `KimiTUI.applyTheme`: it updates `state.theme`, `state.appState.theme`, and notifies components to refresh their palette. +- Persist the choice through `saveTuiConfig` — a component must not write the config file itself. + +> The **hard color rules** (no chalk named colors, contrast ratios, no module-top-level cached styled functions, add a `ColorPalette` token before inventing a color) are normative and guard-enforced — they live in `apps/kimi-code/AGENTS.md`. This skill only covers the mechanics. + +## Before you submit + +- Run lint / format / test on the files you changed. +- For any dialog/selector/input/toggle list, walk the self-check list at the end of [DESIGN.md](./DESIGN.md). +- Keep `printableChar()` for printable-key comparisons (CI guard) and `chalk.hex(colors.)` for color (CI guard). diff --git a/.changeset/unify-tui-dialog-ux.md b/.changeset/unify-tui-dialog-ux.md new file mode 100644 index 000000000..2bcd162b6 --- /dev/null +++ b/.changeset/unify-tui-dialog-ux.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Unify the interaction and visuals across TUI dialogs and selectors. diff --git a/AGENTS.md b/AGENTS.md index dcadd9f19..ffe93c6aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map -- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. +- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index c9ec362bd..11184d958 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -2,6 +2,8 @@ This file only contains rules local to `apps/kimi-code`. For cross-repo rules, see the root `AGENTS.md`. +> **Writing or modifying the TUI?** Use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). It covers the architecture orientation, where new features go, test placement, theme mechanics, and the dialog interaction/visual spec (`DESIGN.md`). This file keeps only the map, boundaries, and hard constraints. + ## TUI File Layout `apps/kimi-code` is the terminal UI / CLI app. The entry chain is: @@ -13,7 +15,9 @@ Main directories: - `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on. - `src/cli/`: command-line arguments, subcommands, and CLI startup. - `src/tui/`: the interactive terminal UI. -- `src/tui/kimi-tui.ts`: the TUI master assembler, responsible for wiring state, layout, editor, session, SDK events, and dialogs together. +- `src/tui/kimi-tui.ts`: the `KimiTUI` coordinator — wires state, layout, editor, session, SDK events, and dialogs together, and dispatches slash-command handlers. Heavy logic is delegated to `controllers/`, not accumulated here. +- `src/tui/tui-state.ts`: `TUIState`, `createTUIState`, `createInitialAppState` — the single global UI-state shape. +- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`. - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. - `src/tui/components/`: pi-tui components, organized by UI type. - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. @@ -24,69 +28,22 @@ Main directories: - `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on. - `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane. - `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI. -- `src/tui/theme/`: themes, color tokens, style helpers, and the pi-tui markdown theme. +- `src/tui/theme/`: themes, color tokens, style helpers, terminal-background detection, and the pi-tui markdown theme. - `src/tui/utils/`: TUI-only utility functions. - `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on. ## Module Responsibilities - `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI. -- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `commands`, `components`, `reverse-rpc`, or `utils` first. +- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `controllers`, `commands`, `components`, `reverse-rpc`, or `utils` first. +- `controllers` own the heavy, independently-testable slices (event routing, streaming render, session replay, tasks browser, editor keyboard, auth). Event-routing and rendering logic belong here, not on the `KimiTUI` class. - `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward. - `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly. - `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response. - `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly. - `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`. -- Resume replay orchestration lives in the `Session Replay` section of `KimiTUI`, because it intentionally drives the same stateful render hooks as live events. Stateless replay parsing, limiting, and projection helpers belong in `src/tui/utils/message-replay.ts`. - `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code. -## KimiTUI Internal Sections - -`src/tui/kimi-tui.ts` is large. When you modify it, place code into the existing responsibility section — do not just drop it where it happens to be convenient. - -- Types and state creation: `KimiTUIStartupInput`, `TUIState`, `createInitialAppState`, `createTUIState`. Before adding new global UI state, decide whether it really belongs in `TUIState`. -- Startup helpers: slash commands, autocomplete, skill commands, input history. -- Lifecycle: `start`, `init`, `stop`. They only handle startup/shutdown order — do not stuff feature implementations into them. -- Layout and editor: `buildLayout`, `setupEditorHandlers`, external editor, clipboard image, exit shortcuts. -- User input: `handleUserInput`, `executeSlashCommand`, `handleBuiltInSlashCommand`, `sendNormalUserInput`. -- Sending and queueing: `enqueueMessage`, `sendMessageInternal`, `sendMessage`, `steerMessage`, `finalizeTurn`. -- Session management: create, restore, switch, close, sync runtime state, subscribe to session events. -- Session replay: hydrate resume snapshots, drive replay records through live render hooks, and clean up transient replay state. -- Event routing: `handleEvent` only dispatches; concrete events go into the corresponding `handleXxx`. -- Streaming rendering: assistant delta, thinking, tool call, tool result, compaction, subagent, background agent. -- Transcript: `createTranscriptComponent`, `appendTranscriptEntry`, read/tool/agent group aggregation. -- Activity / queue / footer: `updateActivityPane`, `resolveActivityPaneMode`, `updateQueueDisplay`, terminal progress. -- Dialogs / selectors: help, session picker, editor/model/thinking/theme/permission/settings selectors, approval / question panels. -- Slash command handlers: `handleThemeCommand`, `handleModelCommand`, `handlePlanCommand`, `handleCompactCommand`, `handleLoginCommand`, and so on. - -If a section keeps growing, split pure functions, state projections, presentation components, and handler logic into the corresponding directories rather than continuing to expand `KimiTUI`. - -## Where New Features Go - -The feature type decides where it lands: - -- New CLI arguments: change `src/cli/commands.ts` / `src/cli/options.ts`, then pass them into the TUI via `src/cli/run-shell.ts`. Do not let the CLI operate on the session directly. -- New CLI subcommands: put them under `src/cli/sub/`, with non-interactive command logic only; when SDK access is needed, go through `@moonshot-ai/kimi-code-sdk`. -- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `utils` or focused components when it has no reason to stay in `KimiTUI`. -- New skill-derived commands: hook into `buildSkillSlashCommands` / the skill command map — do not hard-code a single skill. -- New transcript message types: define the data shape in `src/tui/types.ts`, add or extend a component under `components/messages/`, and register the renderer in `createTranscriptComponent`. -- New tool-result display: prefer extending `components/messages/tool-renderers/registry.ts` and the corresponding renderer; do not stack branches inside `ToolCallComponent`. -- New popup / selector: put it under `components/dialogs/` and mount it via `mountEditorReplacement`; if the trigger comes from an SDK callback, also check whether `reverse-rpc/` needs an adapter/controller/handler. -- New SDK event handling: add the dispatch in `handleEvent`, then add the corresponding `handleXxx`. If the event simply maps to a transcript entry. -- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration. New resume replay behavior belongs in the `Session Replay` section and should reuse live rendering paths where possible. -- New status bar, activity area, or queue display: change `chrome/footer`, `panes/activity`, `panes/queue`, and the corresponding `updateXxx` method. -- New configuration option: first change the read/write and schema in `src/tui/config.ts`, then wire the settings UI; when persistence is needed, go through `saveTuiConfig`. -- New constants: constants shared by CLI/TUI and not copy belong in `src/constant/`; non-copy constants reused only within the TUI belong in `src/tui/constant/`. Component-local copy, option labels, help descriptions, dialog title/footer text — keep these next to the corresponding component or command, do not centralize them into a global copy constants module. -- New general-purpose capability: if it does not depend on TUI state, put it under `src/utils/`; if it depends on TUI state or a component, put it under `src/tui/utils/`. - -Test placement rules: - -- Component behavior tests live next to the corresponding component's tests. -- Command parsing tests go under `test/tui/commands/`. -- reverse-rpc tests go under `test/tui/reverse-rpc/`. -- Pure utility tests go next to the corresponding utils tests. -- Do not create a generic `some-feature.test.ts` just to land a small feature. - ## TUI Coding Conventions - Do not over-encapsulate, especially for one- or two-line functions — do not introduce a two-layer wrapper, just inline. @@ -94,23 +51,9 @@ Test placement rules: - Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code. - Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI. -## How to Set Themes +## Color Rules (normative) -Themes are managed centrally under `src/tui/theme/`: - -- `colors.ts` defines semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. -- `styles.ts` builds common chalk helpers on top of `ColorPalette`. -- `pi-tui-theme.ts` produces the theme configuration markdown / pi-tui requires. -- `bundle.ts` packs `colors`, `styles`, and `markdownTheme` into a `KimiTUIThemeBundle`. -- `index.ts` / `detect.ts` handle the theme type and auto/dark/light resolution. - -When setting or switching themes: - -- The UI entry goes through `ThemeSelectorComponent`, `handleThemeCommand`, and `applyThemeChoice`. -- The real apply step goes through `KimiTUI.applyTheme`, which should update `state.theme`, `state.appState.theme`, and notify the relevant components to refresh their palette. -- Persisting the user's choice goes through `saveTuiConfig`. Do not let a component write the config file itself. - -When writing color: +The theme apply/switch mechanics live in the `write-tui` skill. The following rules are hard and guard-enforced: - Do not use chalk named colors such as `chalk.red`, `chalk.cyan`, `chalk.white`, `chalk.gray`, `chalk.dim`, or `chalk.yellow` directly. - If a component already has `colors`, use `chalk.hex(colors.)(text)`. @@ -118,8 +61,7 @@ When writing color: - When new visual semantics have no token, first add a semantic field to `ColorPalette`, and fill in both `darkColors` and `lightColors`. - In light themes, text tokens against a white background must be at least 4.5:1; borders and large chrome must be at least 3:1. - Do not cache styled chalk functions at module top level. Theme switching must take effect within a single render, so styles must be generated on the render path from the current palette. - -After a theme change, non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. +- Non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. `test/tui/chalk-named-color-guard.test.ts` enforces this in CI. ## General Coding Requirements diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 4a9423508..0420e07a8 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -156,7 +156,9 @@ async function showPluginsPicker( pluginHint: options?.pluginHint, colors: host.state.theme.colors, onSelect: (selection) => { - host.restoreEditor(); + // Each branch of the handler either mounts the next view or restores + // the editor itself, so do not pre-restore here — that would flash the + // editor for in-place actions like toggling a plugin. void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { host.showError(`/plugins failed: ${formatErrorMessage(error)}`); }); @@ -181,7 +183,8 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri source: marketplace.source, colors: host.state.theme.colors, onSelect: (selection) => { - host.restoreEditor(); + // Every marketplace action re-mounts a picker, so let the handler do + // the mounting — pre-restoring the editor here would flash. void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => { host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); }); @@ -217,7 +220,8 @@ async function showPluginMcpPicker( serverHint: options?.serverHint, colors: host.state.theme.colors, onSelect: (selection) => { - host.restoreEditor(); + // Every MCP action re-mounts a picker, so let the handler do the + // mounting — pre-restoring the editor here would flash on toggle. void handlePluginMcpSelection(host, selection).catch((error: unknown) => { host.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`); }); @@ -292,6 +296,7 @@ async function handlePluginsOverviewSelection( await showPluginsPicker(host); return; case 'show-list': + host.restoreEditor(); await renderPluginsList(host); return; case 'toggle': { @@ -316,6 +321,7 @@ async function handlePluginsOverviewSelection( await showPluginsPicker(host); return; case 'info': + host.restoreEditor(); await renderPluginInfo(host, selection.id); return; } @@ -475,5 +481,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'pending /new'; + return 'require run /new to apply'; } diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 15b5b705f..e8c4edfc1 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -222,8 +222,8 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { host.showStatus(`Provider added: ${entry.name ?? providerId}`); // Build a merged model dictionary that includes existing models plus the - // newly-persisted provider's models, so the TabbedModelSelectorComponent - // shows all tabs. + // newly-persisted provider's models, so the tabbed selector shows every + // provider's tab (the new provider's tab starts active via initialTabId). const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); const mergedModels = { ...stateModels }; @@ -316,7 +316,6 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise const firstNewProvider = firstNewAlias ? stateModels[firstNewAlias]?.provider : addedProviderIds[0]; - const selector = new TabbedModelSelectorComponent({ models: stateModels, currentValue: host.state.appState.model, diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 8133a9f36..7853f7e23 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -193,7 +193,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { `${key('↑↓')} ${dim('line')} ` + `${key('PgUp/PgDn')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + - `${key('Q/Esc/Ctrl+E')} ${dim('back')}`; + `${key('Q/Esc/Ctrl+E')} ${dim('cancel')}`; const left = ` ${keys}`; const leftW = visibleWidth(left); const rightW = visibleWidth(position); diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index a07b4a3b5..d5375d776 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -18,6 +18,7 @@ import { } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -49,8 +50,6 @@ export interface ChoicePickerOptions { readonly onCancel: () => void; } -const CURRENT_MARK = '← current'; - function wrapDescription(text: string, width: number): string[] { const maxWidth = Math.max(1, width); const words = text @@ -107,11 +106,10 @@ export class ChoicePickerComponent extends Container implements Focusable { this.list.pageDown(); return; } - if ( - matchesKey(data, Key.enter) || - matchesKey(data, Key.space) || - printableChar(data) === ' ' - ) { + // Enter always selects. Space selects too — but only when the list is not + // searchable; in a searchable list a space must reach the query instead. + const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; + if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { const chosen = this.list.selected(); if (chosen !== undefined) this.opts.onSelect(chosen.value); return; @@ -125,6 +123,9 @@ export class ChoicePickerComponent extends Container implements Focusable { const view = this.list.view(); const options = view.items; + // Header mirrors the model dialog (see model-selector.ts): border, title + // with a "(type to search)" suffix until you type, the hint, a blank, then + // the search line. Key vocabulary is lowercase to match every list dialog. const navParts = ['↑↓ navigate']; if (view.page.pageCount > 1) navParts.push('←→ page'); navParts.push('Enter select', 'Esc cancel'); @@ -135,19 +136,17 @@ export class ChoicePickerComponent extends Container implements Focusable { const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix, - ]; - if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); - } - lines.push( this.opts.formatHint === undefined ? chalk.hex(colors.textMuted)(` ${hint}`) : this.opts.formatHint(` ${hint}`, colors), - ); + ]; if (this.opts.notice !== undefined) { lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`)); } lines.push(''); + if (searchable && view.query.length > 0) { + lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + } if (options.length === 0) { lines.push(chalk.hex(colors.textMuted)(' No matches')); @@ -156,7 +155,7 @@ export class ChoicePickerComponent extends Container implements Focusable { const opt = options[i]!; const isSelected = i === view.selectedIndex; const isCurrent = opt.value === this.opts.currentValue; - const pointer = isSelected ? '❯' : ' '; + const pointer = isSelected ? SELECT_POINTER : ' '; const labelStyle = optionLabelStyle(opt, isSelected, colors); let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); line += labelStyle(opt.label); diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index 9f10471ac..ec2f1d389 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -5,7 +5,8 @@ * * Geometry mirrors `ApiKeyInputDialogComponent` so the chrome stays * consistent with the API-key login flow. Two fields, switched with - * Tab / Shift-Tab; Enter on the last field submits, Esc cancels. + * Tab / Shift-Tab / Up / Down; Enter advances to the next field (and submits + * on the last field), Esc cancels. Both fields are required. */ import { @@ -34,7 +35,8 @@ const TITLE = 'Import custom provider registry'; const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.'; const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.'; const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.'; -const FOOTER = 'Tab to switch · Enter to submit · Esc to cancel'; +const FOOTER_NOT_LAST = 'Tab / ↑↓ to switch · Enter for next field · Esc to cancel'; +const FOOTER_LAST = 'Tab / ↑↓ to switch · Enter to submit · Esc to cancel'; type FieldId = 'url' | 'token'; @@ -83,11 +85,13 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.onDone = onDone; this.colors = colors; if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl); + // Enter on the URL field advances to the token field; Enter on the token + // (last) field submits. this.urlInput.onSubmit = () => { - this.handleEnter(); + this.focusField('token'); }; this.tokenInput.onSubmit = () => { - this.handleEnter(); + this.handleSubmit(); }; } @@ -106,6 +110,14 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.toggleField(); return; } + if (matchesKey(data, Key.down)) { + this.focusField('token'); + return; + } + if (matchesKey(data, Key.up)) { + this.focusField('url'); + return; + } if (this.hint !== 'none') { this.hint = 'none'; @@ -142,7 +154,9 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const footerStyled = chalk.hex(this.colors.textDim)( + this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, + ); const urlLabelText = 'Registry URL'; const tokenLabelText = 'Bearer token'; @@ -198,11 +212,15 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo } private toggleField(): void { - this.hint = 'none'; - this.activeField = this.activeField === 'url' ? 'token' : 'url'; + this.focusField(this.activeField === 'url' ? 'token' : 'url'); } - private handleEnter(): void { + private focusField(field: FieldId): void { + this.hint = 'none'; + this.activeField = field; + } + + private handleSubmit(): void { if (this.done) return; const urlValue = this.urlInput.getValue().trim(); @@ -213,6 +231,11 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.activeField = 'url'; return; } + if (tokenValue.length === 0) { + this.hint = 'token-empty'; + this.activeField = 'token'; + return; + } this.done = true; this.onDone({ kind: 'ok', value: { url: urlValue, apiKey: tokenValue } }); diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 1c26dec34..f096e11c4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -34,7 +34,7 @@ export interface HelpPanelCommand { /** Static list — keep in sync with the global editor bindings. */ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ { keys: 'Shift-Tab', description: 'Toggle plan mode' }, - // { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, + { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, @@ -110,7 +110,7 @@ export class HelpPanelComponent extends Container implements Focusable { const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); const lines: string[] = [ accent('─'.repeat(width)), - accent.bold(' help ') + muted('· Esc / Enter / q to close · ↑↓ scroll'), + accent.bold(' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 38ce8d876..e9ba8c64d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -4,13 +4,14 @@ import { Key, matchesKey, truncateToWidth, + visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; -import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -20,6 +21,11 @@ type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; interface ModelChoice { readonly alias: string; readonly model: ModelAlias; + /** Model display name (left column). */ + readonly name: string; + /** Provider display name (right column). */ + readonly provider: string; + /** Combined text the fuzzy filter matches against (name + provider). */ readonly label: string; } @@ -57,18 +63,19 @@ export interface ModelSelectorOptions { readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ readonly pageSize?: number; - /** When true, the hint line includes a Tab/Shift+Tab provider switch tip. */ + /** When true, the hint line mentions the Tab provider switch — set by + * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; readonly onSelect: (selection: ModelSelection) => void; readonly onCancel: () => void; } function createModelChoices(models: Record): readonly ModelChoice[] { - return Object.entries(models).map(([alias, cfg]) => ({ - alias, - model: cfg, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const name = modelDisplayName(alias, cfg); + const provider = providerDisplayName(cfg.provider); + return { alias, model: cfg, name, provider, label: `${name} (${provider})` }; + }); } function thinkingAvailability(model: ModelAlias): ThinkingAvailability { @@ -85,11 +92,20 @@ function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { return thinkingDraft; } +/** + * Flat, searchable single-list model picker. + * + * One navigation axis: ↑/↓ move the cursor (PgUp/PgDn page), typing fuzzy-filters + * across every provider (provider name included), and ←/→ toggle the thinking + * draft for models that support it. There are no provider tabs — filtering by + * typing a provider name replaces them. See .agents/skills/write-tui/DESIGN.md. + */ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; private readonly list: SearchableList; - private thinkingDraft: boolean; + /** Per-model thinking override set by ←/→; absent → the capability default. */ + private readonly thinkingOverrides = new Map(); constructor(opts: ModelSelectorOptions) { super(); @@ -104,7 +120,18 @@ export class ModelSelectorComponent extends Container implements Focusable { initialIndex: Math.max(selectedIdx, 0), searchable: opts.searchable === true, }); - this.thinkingDraft = opts.currentThinking; + } + + /** + * Thinking draft for a model: an explicit ←/→ override when set, otherwise + * the live thinking state for the active model, otherwise On for any other + * thinking-capable model (a capable model should default to thinking on). + */ + private draftFor(choice: ModelChoice): boolean { + const override = this.thinkingOverrides.get(choice.alias); + if (override !== undefined) return override; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinking; + return thinkingAvailability(choice.model) !== 'unsupported'; } handleInput(data: string): void { @@ -114,46 +141,49 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } - const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { - const ch = printableChar(data); - if (ch === '/') { - this.thinkingDraft = !this.thinkingDraft; - return; - } + // ↑/↓, PgUp/PgDn, and — when searchable — typing + Backspace. + if (this.list.handleKey(data)) { + return; } - if (this.list.handleKey(data)) { - // Consumed by SearchableList (↑/↓/PgUp/PgDn/typing/Backspace). - return; - } - if (matchesKey(data, Key.left)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.right)) { - this.list.pageDown(); + // Left/Right toggle the thinking draft for models that support it. + if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { + const selected = this.selectedChoice(); + if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { + this.thinkingOverrides.set(selected.alias, !this.draftFor(selected)); + } return; } + if (matchesKey(data, Key.enter)) { + const selected = this.selectedChoice(); if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.thinkingDraft), + thinking: effectiveThinking(selected.model, this.draftFor(selected)), }); } } override render(width: number): string[] { const { colors } = this.opts; + const searchable = this.opts.searchable === true; const view = this.list.view(); + const totalCount = Object.keys(this.opts.models).length; + const titleSuffix = - view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 + ? chalk.hex(colors.textMuted)(' (type to search)') + : ''; + + // "type to search" already lives in the title suffix, so the hint only + // surfaces the backspace shortcut once a query is active. const hintParts: string[] = []; - if (this.opts.providerSwitchHint) { - hintParts.push('Tab/Shift+Tab provider'); - } - hintParts.push('↑↓ model', '←→ page', '/ thinking', 'Enter apply', 'Esc cancel'); + if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); + hintParts.push('↑↓ navigate'); + if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); + hintParts.push('Enter select', 'Esc cancel'); + const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(' Select a model') + titleSuffix, @@ -161,43 +191,63 @@ export class ModelSelectorComponent extends Container implements Focusable { '', ]; - if (view.query.length > 0) { + if (searchable && view.query.length > 0) { lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); } if (view.items.length === 0) { lines.push(chalk.hex(colors.textMuted)(' No matches')); } else { + // Column width for model names so the provider column lines up. Capped so + // the provider + "← current" marker still fit on normal terminal widths. + const nameCap = Math.max(8, Math.floor(width * 0.5)); + let nameWidth = 0; + for (let i = view.page.start; i < view.page.end; i++) { + const choice = view.items[i]; + if (choice !== undefined) nameWidth = Math.max(nameWidth, visibleWidth(choice.name)); + } + nameWidth = Math.min(nameWidth, nameCap); + for (let i = view.page.start; i < view.page.end; i++) { const choice = view.items[i]; if (choice === undefined) continue; const isSelected = i === view.selectedIndex; const isCurrent = choice.alias === this.opts.currentValue; - const pointer = isSelected ? '❯' : ' '; - const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const pointer = isSelected ? SELECT_POINTER : ' '; + const nameStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); + const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); - line += labelStyle(choice.label); + line += nameStyle(truncatedName) + namePad; + line += ' ' + chalk.hex(colors.textMuted)(choice.provider); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)('← current'); + line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); } lines.push(line); } } - if (view.page.pageCount > 1) { + // Scroll / match indicator. + if (view.query.length > 0) { lines.push(''); lines.push( - chalk.hex(colors.textMuted)( - ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, - ), + chalk.hex(colors.textMuted)(` ${String(view.items.length)} / ${String(totalCount)}`), ); + } else { + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + } } lines.push(''); - lines.push(chalk.hex(colors.textMuted)(' Thinking (/ to toggle)')); const selected = this.selectedChoice(); if (selected !== undefined) { - lines.push(this.renderThinkingControl(selected.model)); + const availability = thinkingAvailability(selected.model); + const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; + lines.push(chalk.hex(colors.textMuted)(thinkingHeader)); + lines.push(this.renderThinkingControl(selected)); } lines.push(''); lines.push(chalk.hex(colors.primary)('─'.repeat(width))); @@ -208,20 +258,21 @@ export class ModelSelectorComponent extends Container implements Focusable { return this.list.selected(); } - private renderThinkingControl(model: ModelAlias): string { + private renderThinkingControl(choice: ModelChoice): string { const { colors } = this.opts; const segment = (label: string, active: boolean): string => active ? chalk.hex(colors.primary).bold(`[ ${label} ]`) : chalk.hex(colors.text)(` ${label} `); - const availability = thinkingAvailability(model); + const availability = thinkingAvailability(choice.model); if (availability === 'always-on') { return ` ${segment('Always on', true)}`; } if (availability === 'unsupported') { return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; } - return ` ${segment('On', this.thinkingDraft)} ${segment('Off', !this.thinkingDraft)}`; + const draft = this.draftFor(choice); + return ` ${segment('On', draft)} ${segment('Off', !draft)}`; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index f3600b5c2..49091009e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -9,6 +9,7 @@ import { import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; @@ -73,7 +74,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } @@ -109,7 +110,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } return; } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) { + if (matchesKey(data, Key.enter)) { if (pluginId !== undefined) { this.opts.onSelect({ kind: 'info', id: pluginId }); return; @@ -122,13 +123,13 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus override render(width: number): string[] { const { colors, plugins } = this.opts; const hint = - '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc close'; + '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel'; const pluginItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(' Plugins'), - pluginShortcutHint(` ${hint}`, colors), + mutedHintLine(` ${hint}`, colors), '', sectionLabel(`Installed plugins (${plugins.length})`, colors), ]; @@ -157,7 +158,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { const { colors } = this.opts; const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; + const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); @@ -172,7 +173,7 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -205,7 +206,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } @@ -217,7 +218,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); return; } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space) || printableChar(data) === ' ') { + if (matchesKey(data, Key.enter)) { const chosen = this.items[this.selectedIndex]; if (chosen === undefined) return; if (chosen.value === 'back') { @@ -237,7 +238,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(' Official plugins'), - pluginShortcutHint(' ↑↓ navigate · Enter/Space install/update · ←/Esc back', colors), + mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel', colors), chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`), '', sectionLabel(`Marketplace (${entries.length})`, colors), @@ -265,7 +266,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { const { colors } = this.opts; const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; + const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); @@ -275,7 +276,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -315,7 +316,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } @@ -354,7 +355,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), - pluginShortcutHint(' ↑↓ navigate · Enter/Space enable/disable · ←/Esc back', colors), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), '', sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), ]; @@ -381,7 +382,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { const { colors } = this.opts; const selected = index === this.selectedIndex; - const pointer = selected ? '❯' : ' '; + const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); @@ -395,7 +396,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(pluginShortcutHint(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -417,7 +418,7 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { super({ title: `Remove ${opts.displayName} (${opts.id})?`, hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', - formatHint: pluginShortcutHint, + formatHint: mutedHintLine, options: [ { value: REMOVE_CONFIRM_CANCEL, @@ -594,27 +595,8 @@ function statusStyle( return chalk.hex(colors.warning); } -function pluginShortcutHint(text: string, colors: ColorPalette): string { - const shortcutPattern = /D(?= remove)|M(?= MCP)|Space|Enter|Esc|[←→↑↓]/gu; - let output = ''; - let offset = 0; - - for (const match of text.matchAll(shortcutPattern)) { - const index = match.index; - if (index === undefined) continue; - const token = match[0]; - output += chalk.hex(colors.textMuted)(text.slice(offset, index)); - output += shortcutTokenStyle(token, colors)(token); - offset = index + token.length; - } - - output += chalk.hex(colors.textMuted)(text.slice(offset)); - return output; -} - -function shortcutTokenStyle(token: string, colors: ColorPalette): (text: string) => string { - if (token === 'D') return chalk.hex(colors.error).bold; - return chalk.hex(colors.primary).bold; +function mutedHintLine(text: string, colors: ColorPalette): string { + return chalk.hex(colors.textMuted)(text); } function wrapOverviewDescription(text: string, width: number): string[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 8ff2a21a2..2cc7fcccf 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -12,12 +12,12 @@ * * Keyboard: * - ↑ / ↓ move highlight - * - ← / → page up / down + * - ← / → · PgUp/PgDn page * - Enter on `[ Add New Platform ]` → `onAdd()` - * - d (lowercase) delete with inline `[y/N]` confirmation + * - D delete with inline `[y/N]` confirmation * on a source row → `onDeleteSource(providerIds)` * on `[ Add New Platform ]` → ignored - * - Esc `onClose()` (outside the confirm substate) + * - Esc `onClose()` (outside confirm) * * The `[y/N]` confirmation is a transient substate handled in-component: * while armed, only `y` / `Y` / `n` / `N` / `Esc` are honored and the @@ -46,6 +46,7 @@ import { import chalk from 'chalk'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -92,7 +93,7 @@ type Row = SourceRow | AddRow; const ADD_ROW_LABEL = '[ Add New Platform ]'; const PAGE_SIZE = 8; -const HEADER_HINT = '↑↓ navigate · ←→ page · d delete · Esc close'; +const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel'; // Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload. // Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import @@ -225,7 +226,7 @@ export class ProviderManagerComponent extends Container implements Focusable { (row) => row.kind === 'source' && row.providerIds.includes(opts.activeProviderId ?? ''), ) : -1; - this.selectedIndex = activeIdx >= 0 ? activeIdx : 0; + this.selectedIndex = Math.max(activeIdx, 0); this.confirm = undefined; } @@ -261,6 +262,7 @@ export class ProviderManagerComponent extends Container implements Focusable { this.invalidate(); } + /** Rows after applying the active fuzzy filter; the add-row is always kept. */ private page(): PageView { return pageView(this.rows.length, this.selectedIndex, PAGE_SIZE); } @@ -276,42 +278,45 @@ export class ProviderManagerComponent extends Container implements Focusable { return; } + const rows = this.rows; + if (matchesKey(data, Key.up)) { - if (this.rows.length === 0) return; + if (rows.length === 0) return; this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.invalidate(); return; } if (matchesKey(data, Key.down)) { - if (this.rows.length === 0) return; - this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1); + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + 1); this.invalidate(); return; } if (matchesKey(data, Key.left) || matchesKey(data, Key.pageUp)) { - if (this.rows.length === 0) return; + if (rows.length === 0) return; this.selectedIndex = Math.max(0, this.selectedIndex - PAGE_SIZE); this.invalidate(); return; } if (matchesKey(data, Key.right) || matchesKey(data, Key.pageDown)) { - if (this.rows.length === 0) return; - this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + PAGE_SIZE); + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + PAGE_SIZE); this.invalidate(); return; } if (matchesKey(data, Key.enter)) { - const selected = this.rows[this.selectedIndex]; + const selected = rows[this.selectedIndex]; if (selected?.kind === 'add') { this.opts.onAdd(); } return; } - const k = printableChar(data); - if (k === 'd') { + // Delete the highlighted provider with the D key. + const ch = printableChar(data); + if (ch === 'd' || ch === 'D') { this.armDeleteConfirm(); } } @@ -353,20 +358,22 @@ export class ProviderManagerComponent extends Container implements Focusable { const { colors } = this.opts; const lines: string[] = []; + // Header shape mirrors the model dialog (see model-selector.ts): a single + // top border, the title, the keymap hint, then a blank line. No inner + // border under the title. const border = chalk.hex(colors.primary)('─'.repeat(width)); lines.push(border); - lines.push(renderHeader(width, colors)); - lines.push(border); + lines.push(chalk.hex(colors.primary).bold(' Providers')); + lines.push(chalk.hex(colors.textMuted)(' ' + HEADER_HINT)); lines.push(''); - lines.push(renderColumnHeader(width, colors)); - - if (this.rows.length === 0) { + const rows = this.rows; + if (rows.length === 0) { lines.push(chalk.hex(colors.textMuted)(' No providers configured.')); } else { const view = this.page(); for (let i = view.start; i < view.end; i++) { - const row = this.rows[i]; + const row = rows[i]; if (row === undefined) continue; for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) { lines.push(line); @@ -387,7 +394,6 @@ export class ProviderManagerComponent extends Container implements Focusable { ), ); } - lines.push(renderFooterHint(width, colors)); } lines.push(border); @@ -403,47 +409,35 @@ export class ProviderManagerComponent extends Container implements Focusable { } } -function renderHeader(width: number, colors: ColorPalette): string { - const title = chalk.hex(colors.primary).bold(' Providers'); - return truncateToWidth(title, width, '…'); -} - -function renderColumnHeader(width: number, colors: ColorPalette): string { - const muted = chalk.hex(colors.textMuted); - const padded = padCell('', Math.max(0, width - 2)); - return ` ${muted(padded)}`; -} - -function renderFooterHint(width: number, colors: ColorPalette): string { - const hint = chalk.hex(colors.textMuted)(' ' + HEADER_HINT); - return truncateToWidth(hint, width, '…'); -} function renderRow( row: Row, ctx: { isSelected: boolean; width: number; colors: ColorPalette }, ): string[] { const { isSelected, width, colors } = ctx; - const pointer = isSelected ? '❯' : ' '; + const pointer = isSelected ? SELECT_POINTER : ' '; const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim); - const indicatorStyle = chalk.hex(colors.success); - const labelStyle = - row.kind === 'add' - ? chalk.hex(colors.textMuted) - : isSelected - ? chalk.hex(colors.primary).bold - : chalk.hex(colors.text); + // The synthetic "Add New Platform" row is an action/CTA: keep it in the brand + // color so it never reads as disabled, and bold it when selected (matching + // the other rows' selected treatment). + const labelStyle = isSelected + ? chalk.hex(colors.primary).bold + : row.kind === 'add' + ? chalk.hex(colors.primary) + : chalk.hex(colors.text); - const indicatorRendered = - row.kind === 'source' && row.hasActive ? indicatorStyle('● ') : ' '; + // The active provider is flagged with a trailing "← current" (success), + // matching the model selector's current-item marker — see .agents/skills/write-tui/DESIGN.md. + const isActive = row.kind === 'source' && row.hasActive; + const marker = isActive ? ` ${CURRENT_MARK}` : ''; - // Reserve 2 leading spaces + 2 for pointer + 2 for indicator. - const labelWidth = Math.max(0, width - 6); + // Reserve 2 leading spaces + 2 for the pointer + room for the marker. + const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); const labelText = truncateToWidth(row.label, labelWidth, '…'); - const styledLabel = labelStyle(labelText); - const labelPadded = styledLabel + ' '.repeat(Math.max(0, labelWidth - visibleWidth(labelText))); + let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; + if (isActive) line += chalk.hex(colors.success)(marker); - const lines: string[] = [` ${pointerStyle(`${pointer} `)}${indicatorRendered}${labelPadded}`]; + const lines: string[] = [line]; if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) { const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…'); @@ -452,9 +446,3 @@ function renderRow( return lines; } - -function padCell(text: string, width: number): string { - const w = visibleWidth(text); - if (w >= width) return truncateToWidth(text, width, '…'); - return text + ' '.repeat(width - w); -} diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 6c31e342c..a21989ebe 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -645,7 +645,7 @@ export class QuestionDialogComponent extends Container implements Focusable { 'type answer', '↵ save', ...(this.totalTabs() > 1 ? ['tab switch'] : []), - 'esc dismiss', + 'esc cancel', ]; return dim(` ${parts.join(' ')}`); } @@ -653,21 +653,21 @@ export class QuestionDialogComponent extends Container implements Focusable { const optionCount = Math.min(this.displayOptions(questionIdx).length, NUMBER_KEYS.length); const numberHint = optionCount <= 1 ? '1' : `1-${String(optionCount)}`; const question = this.request.data.questions[questionIdx]; - if (question === undefined) return dim(' esc dismiss'); + if (question === undefined) return dim(' esc cancel'); const parts: string[] = [ - '▲/▼ select', + '↑↓ select', `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, ]; if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc dismiss'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } private buildSubmitHint(dim: (s: string) => string): string { - const parts: string[] = ['▲/▼ select', '1/2 choose', '↵ confirm']; + const parts: string[] = ['↑↓ select', '1/2 choose', '↵ confirm']; if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc dismiss'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index 29053e64d..a8524cb6f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -13,6 +13,7 @@ import { import chalk from 'chalk'; import { formatSessionLabel } from '#/migration/index'; +import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; export interface SessionRow { @@ -25,7 +26,6 @@ export interface SessionRow { } const ELLIPSIS = '…'; -const CURRENT_BADGE = '(current)'; function formatRelativeTime(ts: number): string { // SessionSummary timestamps come from filesystem stat `*timeMs`, @@ -160,7 +160,7 @@ export class SessionPickerComponent extends Container implements Focusable { } const headerLabel = 'Sessions '; - const headerHint = '(↑↓ navigate, Enter select, Esc cancel)'; + const headerHint = '↑↓ navigate · Enter select · Esc cancel'; const labelWidth = visibleWidth(headerLabel); const hintBudget = Math.max(0, width - labelWidth); const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); @@ -207,18 +207,18 @@ export class SessionPickerComponent extends Container implements Focusable { isCurrent: boolean, ): string[] { const colors = this.colors; - const pointer = isSelected ? '❯' : ' '; + const pointer = isSelected ? SELECT_POINTER : ' '; const indent = ' '; const indentWidth = visibleWidth(indent); const titleColor = isSelected ? colors.primary : colors.text; const titleStyle = isSelected ? chalk.hex(titleColor).bold : chalk.hex(titleColor); const time = formatRelativeTime(session.updated_at); - const badge = isCurrent ? CURRENT_BADGE : ''; + const badge = isCurrent ? CURRENT_MARK : ''; const rawTitle = (session.title ?? session.id).trim() || session.id; const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); - // Inline trailing parts after the title: " <time> (current)". + // Inline trailing parts after the title: "<title> <time> ← current". const trailingParts = [time, badge].filter((p) => p.length > 0); const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : ''; const trailingWidth = visibleWidth(trailingText); diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 5d04b5900..62cd2afec 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -6,12 +6,11 @@ * ['all', ...uniqueProviderIds] (insertion order, deduplicated) * * Each tab owns its own inner ModelSelectorComponent built from the filtered - * subset of models. Up/Down/Enter/Esc/Left/Right are forwarded to the active - * inner selector; Tab / Shift-Tab cycle between tabs. + * subset of models. ↑/↓/Enter/Esc/←/→ (thinking) and typing (filter) are + * forwarded to the active inner selector; Tab / Shift-Tab cycle between tabs. * - * Note: the flat ModelSelectorComponent is intentionally left untouched — the - * OpenPlatform login flow (see promptModelSelectionForOpenPlatform) keeps - * using it directly. + * The active tab is highlighted with a filled background (matching the + * AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md. */ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -67,14 +66,13 @@ export class TabbedModelSelectorComponent extends Container implements Focusable this.opts = opts; this.tabs = buildTabs(opts); - const preferredProvider = - opts.initialTabId ?? - opts.models[opts.selectedValue ?? '']?.provider ?? - opts.models[opts.currentValue]?.provider; - const initialTabIdx = preferredProvider - ? this.tabs.findIndex((tab) => tab.id === preferredProvider) + // Default to the "All" tab. Only an explicit initialTabId (e.g. the + // provider just added via /provider) opens on a specific provider tab — + // the current model is still highlighted inside whichever tab is active. + const initialTabIdx = opts.initialTabId + ? this.tabs.findIndex((tab) => tab.id === opts.initialTabId) : -1; - this.activeIndex = initialTabIdx >= 0 ? initialTabIdx : 0; + this.activeIndex = Math.max(initialTabIdx, 0); this.syncFocusToActive(); } @@ -101,14 +99,19 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Inject the tab strip just after the inner selector's top divider so - // it sits inside the frame rather than above it. + // Layout: divider, title, hint, blank, tab strip, blank, then the model + // list. The inner selector's blank line (inner[3]) separates the hint from + // the tab strip; an extra blank separates the tabs from their list. const stripLine = this.renderTabStrip(width); - const out: string[] = []; - out.push(inner[0] ?? ''); - out.push(stripLine); - out.push(chalk.hex(this.opts.colors.primary)('─'.repeat(width))); - for (let i = 1; i < inner.length; i++) out.push(inner[i]!); + const out: string[] = [ + inner[0] ?? '', + inner[1] ?? '', + inner[2] ?? '', + inner[3] ?? '', + stripLine, + '', + ]; + for (let i = 4; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -126,30 +129,30 @@ export class TabbedModelSelectorComponent extends Container implements Focusable } } + /** Style a tab segment. The active tab is filled with the brand background + * (matching the AskUserQuestion dialog); inactive tabs are muted. Both have + * the same visible width so switching never shifts the layout. */ + private styleTab(label: string, isActive: boolean): string { + const { colors } = this.opts; + const cell = ` ${label} `; + return isActive + ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) + : chalk.hex(colors.textMuted)(cell); + } + private renderTabStrip(width: number): string { const { colors } = this.opts; const segments: string[] = []; for (let i = 0; i < this.tabs.length; i++) { const tab = this.tabs[i]!; - const isActive = i === this.activeIndex; - const label = ` ${tab.label} `; - const styled = isActive - ? chalk.hex(colors.primary).bold(`[${label}]`) - : chalk.hex(colors.textMuted)(` ${label} `); - segments.push(styled); + segments.push(this.styleTab(tab.label, i === this.activeIndex)); } - // If everything fits with a leading space, show all. + // If everything fits with a leading space, show the whole strip. The + // provider-switch hint lives in the inner selector's hint line, not here. const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); if (1 + totalSegmentWidth <= width) { - const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider'); - let strip = ' ' + segments.join(''); - const available = width - visibleWidth(strip) - 1; - if (available >= visibleWidth(hint) + 1) { - const pad = ' '.repeat(available - visibleWidth(hint)); - strip += pad + hint; - } - return strip; + return ' ' + segments.join(' '); } // Scrolling needed. Find the widest window that contains activeIndex. @@ -196,18 +199,10 @@ export class TabbedModelSelectorComponent extends Container implements Focusable const hasLeft = start > 0; const hasRight = end < segments.length; let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; - strip += segments.slice(start, end).join(''); + strip += segments.slice(start, end).join(' '); if (hasRight) { strip += chalk.hex(colors.textMuted)(' >'); } - - const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider'); - const available = width - visibleWidth(strip) - 1; - if (available >= visibleWidth(hint) + 1) { - const pad = ' '.repeat(available - visibleWidth(hint)); - strip += pad + hint; - } - return strip; } } @@ -224,12 +219,13 @@ function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { } } - const tabs: ModelTab[] = []; - tabs.push({ - id: ALL_TAB_ID, - label: ALL_TAB_LABEL, - selector: makeSelector(opts, opts.models), - }); + const tabs: ModelTab[] = [ + { + id: ALL_TAB_ID, + label: ALL_TAB_LABEL, + selector: makeSelector(opts, opts.models), + }, + ]; for (const providerId of providerIds) { const subset: Record<string, ModelAlias> = {}; for (const [alias, model] of entries) { diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index fbd11d76e..b9a525ff5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -248,7 +248,7 @@ export class TaskOutputViewer extends Container implements Focusable { `${key('↑↓')} ${dim('line')} ` + `${key('PgUp/PgDn')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + - `${key('Q/Esc')} ${dim('back')}`; + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; const leftW = visibleWidth(left); const rightW = visibleWidth(position); diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index ee2acc334..44c9f192b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -25,6 +25,7 @@ import { import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; +import { SELECT_POINTER } from '@/tui/constant/symbols'; import type { ColorPalette } from '@/tui/theme/colors'; import { printableChar } from '@/tui/utils/printable-key'; @@ -359,7 +360,7 @@ export class TasksBrowserApp extends Container implements Focusable { const warn = (text: string): string => chalk.hex(colors.warning).bold(text); const line = ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + - `${key('Y')} ${dim('confirm')} ${key('N')} ${dim('cancel')} `; + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; return fitExactly(line, width); } @@ -369,7 +370,7 @@ export class TasksBrowserApp extends Container implements Focusable { `${key('S')} ${dim('stop')}`, `${key('R')} ${dim('refresh')}`, `${key('Tab')} ${dim('filter')}`, - `${key('Q/Esc')} ${dim('exit')} `, + `${key('Q/Esc')} ${dim('cancel')} `, ]; const left = parts.join(' '); const flash = this.props.flashMessage; @@ -462,7 +463,7 @@ export class TasksBrowserApp extends Container implements Focusable { private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { const colors = this.props.colors; - const pointer = selected ? '> ' : ' '; + const pointer = selected ? `${SELECT_POINTER} ` : ' '; const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); const idColor = selected diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 87b0d924f..852216263 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,6 +1,7 @@ import { Container, Text } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; import type { ColorPalette } from '../../theme/colors'; @@ -20,7 +21,7 @@ export class QueuePaneComponent extends Container { const dim = chalk.hex(options.colors.textDim); for (const item of options.messages) { - this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0)); + this.addChild(new Text(accent(` ${SELECT_POINTER} ${item.text}`), 0, 0)); } if (options.messages.length > 0) { diff --git a/apps/kimi-code/src/tui/constant/symbols.ts b/apps/kimi-code/src/tui/constant/symbols.ts index 670b94eb0..bf237484d 100644 --- a/apps/kimi-code/src/tui/constant/symbols.ts +++ b/apps/kimi-code/src/tui/constant/symbols.ts @@ -5,3 +5,9 @@ export const STATUS_BULLET = '● '; // assumes the marker occupies the leading cells. export const USER_MESSAGE_BULLET = '✨ '; export const FAILURE_MARK = '✗ '; + +// Shared selector markers — keep every list picker visually consistent. +// SELECT_POINTER marks the highlighted row; CURRENT_MARK is appended to the +// row that is the currently-active value. See .agents/skills/write-tui/DESIGN.md. +export const SELECT_POINTER = '❯'; +export const CURRENT_MARK = '← current'; diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 285889098..bc119edb4 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -2,20 +2,49 @@ import { describe, expect, it, vi } from 'vitest'; import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; -import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; import { darkColors } from '#/tui/theme/colors'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } describe('ChoicePickerComponent', () => { + it('uses the model-dialog header vocabulary (capitalized keys, "type to search")', () => { + const picker = new ChoicePickerComponent({ + title: 'Add provider', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + ], + colors: darkColors, + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + + const titleIdx = lines.findIndex((l) => l.includes('Add provider')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + // Title carries the same "(type to search)" suffix as /model and /provider. + expect(lines[titleIdx]).toContain('(type to search)'); + expect(lines[titleIdx]).not.toContain('type to filter'); + // Hint sits directly under the title and uses lowercase key vocabulary. + const hint = lines[titleIdx + 1]; + expect(hint).toContain('↑↓ navigate'); + expect(hint).toContain('Enter select'); + expect(hint).toContain('Esc cancel'); + expect(hint).not.toContain('enter select'); + expect(hint).not.toContain('esc cancel'); + // Blank line separates the hint from the body, like the model dialog. + expect(lines[titleIdx + 2]).toBe(''); + }); + it('renders optional descriptions below choice labels', () => { const picker = new ChoicePickerComponent({ title: 'Select permission mode', @@ -56,27 +85,6 @@ describe('ChoicePickerComponent', () => { }); expect(editor.render(120).map(strip)).toContain(' ❯ Vim ← current'); - const model = new ModelSelectorComponent({ - models: { - kimi: { - provider: 'managed:kimi-code', - model: 'kimi-k2', - maxContextSize: 200_000, - displayName: 'Kimi K2', - capabilities: ['thinking'], - }, - }, - currentValue: 'kimi', - currentThinking: true, - colors: darkColors, - onSelect, - onCancel, - }); - const modelOutput = model.render(120).map(strip); - expect(modelOutput).toContain(' ❯ Kimi K2 (Kimi Code) ← current'); - expect(modelOutput).toContain(' Thinking (/ to toggle)'); - expect(modelOutput).toContain(' [ On ] Off '); - const theme = new ThemeSelectorComponent({ currentValue: 'light', colors: darkColors, @@ -114,99 +122,35 @@ describe('ChoicePickerComponent', () => { expect(upgradePreferenceOutput).toContain(' Install new versions in the background.'); }); - it('submits the selected model and inline thinking state', () => { + it('routes Space into the query for searchable lists instead of selecting', () => { const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - kimi: { - provider: 'managed:kimi-code', - model: 'kimi-k2', - maxContextSize: 200_000, - displayName: 'Kimi K2', - capabilities: ['thinking'], - }, - }, - currentValue: 'kimi', - currentThinking: true, + const picker = new ChoicePickerComponent({ + title: 'Select a provider', + options: [ + { value: 'openai', label: 'OpenAI' }, + { value: 'azure', label: 'Azure OpenAI' }, + ], + searchable: true, colors: darkColors, onSelect, onCancel: vi.fn(), }); - picker.handleInput('/'); - picker.handleInput('\r'); - - expect(onSelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + picker.handleInput(' '); + expect(onSelect).not.toHaveBeenCalled(); }); - it('forces always-thinking models on and unsupported models off', () => { + it('selects on Space when the list is not searchable', () => { const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - always: { - provider: 'managed:kimi-code', - model: 'kimi-thinking', - maxContextSize: 200_000, - displayName: 'Kimi Thinking', - capabilities: ['always_thinking'], - }, - plain: { - provider: 'managed:kimi-code', - model: 'kimi-plain', - maxContextSize: 200_000, - displayName: 'Kimi Plain', - capabilities: ['tool_use'], - }, - }, - currentValue: 'always', - currentThinking: false, + const picker = new ChoicePickerComponent({ + title: 'Pick one', + options: [{ value: 'a', label: 'Alpha' }], colors: darkColors, onSelect, onCancel: vi.fn(), }); - expect(picker.render(120).map(strip)).toContain(' [ Always on ]'); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); - - picker.handleInput('\u001B[B'); - expect(picker.render(120).map(strip)).toContain(' [ Off ] unsupported'); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); - }); - - it('keeps the thinking draft when moving across models', () => { - const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { - plain: { - provider: 'managed:kimi-code', - model: 'kimi-plain', - maxContextSize: 200_000, - displayName: 'Kimi Plain', - capabilities: ['tool_use'], - }, - thinking: { - provider: 'managed:kimi-code', - model: 'kimi-thinking', - maxContextSize: 200_000, - displayName: 'Kimi Thinking', - capabilities: ['thinking'], - }, - }, - currentValue: 'plain', - currentThinking: false, - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('\u001B[B'); - picker.handleInput('/'); - picker.handleInput('\u001B[A'); - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); - - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: true }); + picker.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith('a'); }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts new file mode 100644 index 000000000..66bc3badd --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + CustomRegistryImportDialogComponent, + type CustomRegistryImportResult, +} from '#/tui/components/dialogs/custom-registry-import'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const DOWN = `${ESC}[B`; +const UP = `${ESC}[A`; + +function plain(component: CustomRegistryImportDialogComponent, width = 80): string { + return component.render(width).map(strip).join('\n'); +} + +function makeDialog(defaultUrl = 'https://example.com/api.json'): { + dialog: CustomRegistryImportDialogComponent; + onDone: ReturnType<typeof vi.fn>; +} { + const onDone = vi.fn(); + const dialog = new CustomRegistryImportDialogComponent( + onDone as unknown as (r: CustomRegistryImportResult) => void, + darkColors, + defaultUrl, + ); + dialog.focused = true; + return { dialog, onDone }; +} + +describe('CustomRegistryImportDialogComponent', () => { + it('advances from the URL field to the token field on Enter instead of submitting', () => { + const { dialog, onDone } = makeDialog(); + expect(plain(dialog)).toContain('next field'); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(plain(dialog)).toContain('Enter to submit'); + }); + + it('switches fields with Up / Down arrows', () => { + const { dialog } = makeDialog(); + dialog.handleInput(DOWN); + expect(plain(dialog)).toContain('Enter to submit'); + dialog.handleInput(UP); + expect(plain(dialog)).toContain('next field'); + }); + + it('requires a non-empty Bearer token before submitting', () => { + const { dialog, onDone } = makeDialog(); + dialog.handleInput('\r'); // url -> token + dialog.handleInput('\r'); // attempt submit with an empty token + expect(onDone).not.toHaveBeenCalled(); + expect(plain(dialog)).toContain('Bearer token cannot be empty'); + }); + + it('submits the url and token once both are provided', () => { + const { dialog, onDone } = makeDialog(); + dialog.handleInput('\r'); // url -> token + for (const ch of 'sk-tok') dialog.handleInput(ch); + dialog.handleInput('\r'); // submit from the token field + + expect(onDone).toHaveBeenCalledWith({ + kind: 'ok', + value: { url: 'https://example.com/api.json', apiKey: 'sk-tok' }, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts new file mode 100644 index 000000000..0a59030f6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -0,0 +1,221 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; + +function model(displayName: string, capabilities: string[] = ['thinking']): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + } as unknown as ModelAlias; +} + +function text(component: ModelSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('ModelSelectorComponent', () => { + it('lays out the provider as a right column and marks the current model', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + colors: darkColors, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // Model name on the left, provider on the right, with the current marker. + expect(out).toMatch(/❯ Kimi K2\s+Kimi Code ← current/); + // Provider is no longer inlined in parentheses next to the name. + expect(out).not.toContain('Kimi K2 (Kimi Code)'); + }); + + it('toggles thinking with Left/Right (not with "/")', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinking: true, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + // "/" no longer toggles thinking (it used to); here it is simply ignored. + picker.handleInput('/'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + + // Right arrow flips the draft (true -> false). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: false }); + + // Left arrow flips it back. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + }); + + it('shows the Left/Right thinking hint only for toggleable models', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinking: false, + colors: darkColors, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Thinking (←→ to switch)'); + }); + + it('forces always-thinking models on and unsupported models off', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: model('Kimi Thinking', ['always_thinking']), + plain: model('Kimi Plain', ['tool_use']), + }, + currentValue: 'always', + currentThinking: false, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + expect(text(picker)).toContain('[ Always on ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + + picker.handleInput(DOWN); + expect(text(picker)).toContain('[ Off ] unsupported'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + }); + + it('keeps the thinking draft when moving across models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + plain: model('Kimi Plain', ['tool_use']), + thinking: model('Kimi Thinking', ['thinking']), + }, + currentValue: 'plain', + currentThinking: false, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(DOWN); // -> thinking model (defaults On) + picker.handleInput(RIGHT); // toggle -> Off + picker.handleInput(UP); // -> plain + picker.handleInput(DOWN); // -> thinking (the Off override persists) + picker.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: false }); + }); + + it('defaults a thinking-capable model to On but keeps the current model state', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + current: model('Kimi Current', ['thinking']), + other: model('Kimi Other', ['thinking']), + }, + currentValue: 'current', + currentThinking: false, // thinking deliberately off on the active model + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + // The active model reflects its live (off) state. + expect(text(picker)).toContain('[ Off ]'); + picker.handleInput(DOWN); // -> the other thinking-capable model + // A capable, non-active model defaults to On without any toggle. + expect(text(picker)).toContain('[ On ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: true }); + }); + + it('fuzzy-filters by typing and reports a match count', () => { + const onCancel = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, + currentValue: 'k2', + currentThinking: false, + colors: darkColors, + searchable: true, + onSelect: vi.fn(), + onCancel, + }); + + picker.handleInput('t'); + picker.handleInput('u'); + const out = text(picker); + expect(out).toContain('Search: tu'); + expect(out).toContain('Kimi Turbo'); + expect(out).not.toContain('Kimi K2'); + expect(out).toContain('1 / 2'); + + // First Esc clears the query, second Esc cancels. + picker.handleInput(ESC); + expect(onCancel).not.toHaveBeenCalled(); + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('shows a "more" indicator when the list overflows a page', () => { + const models: Record<string, ModelAlias> = {}; + for (let i = 0; i < 12; i++) models[`m${String(i)}`] = model(`Model ${String(i)}`); + const picker = new ModelSelectorComponent({ + models, + currentValue: 'm0', + currentThinking: false, + colors: darkColors, + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // Default page size is 8, so 4 of the 12 models sit below the fold. + expect(text(picker)).toContain('▼ 4 more'); + }); + + it('never renders a line wider than the terminal', () => { + const picker = new ModelSelectorComponent({ + models: { + long: model('A Very Long Model Display Name That Should Be Truncated Hard'), + cjk: model('超长的中文模型名称需要被正确截断处理'), + }, + currentValue: 'long', + currentThinking: false, + colors: darkColors, + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (const width of [20, 40, 80, 120]) { + for (const line of picker.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 078feceda..5a033a2cf 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -13,9 +13,10 @@ import { darkColors } from '#/tui/theme/colors'; import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; const ANSI_SGR = /\[[0-9;]*m/g; -const SGR_SEQUENCE = String.raw`\[[0-9;]*m`; -const HIGHLIGHTED_D_REMOVE = new RegExp(`${SGR_SEQUENCE}(?:${SGR_SEQUENCE})*D(?:${SGR_SEQUENCE})+ remove`, 'g'); const MID = '\u00B7'; +const ESC = String.fromCodePoint(27); +const RIGHT = `${ESC}[C`; +const LEFT = `${ESC}[D`; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -35,10 +36,6 @@ function renderRaw(component: { render(width: number): string[] }, width = 120): return withAnsiColors(() => component.render(width).join('\n')); } -function primaryShortcut(text: string): string { - return withAnsiColors(() => chalk.hex(darkColors.primary).bold(text)); -} - function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } @@ -125,11 +122,7 @@ describe('plugins selector dialogs', () => { expect(out).toContain(`id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`); expect(out).not.toContain('Space disable'); expect(out).not.toContain('Enter info'); - expect(raw.match(HIGHLIGHTED_D_REMOVE)).toHaveLength(1); - expect(raw).toContain(primaryShortcut('Space')); - expect(raw).toContain(primaryShortcut('M')); - expect(raw).toContain(dangerShortcut('D')); - expect(raw).toContain(primaryShortcut('Enter')); + expect(out).toContain('Space toggle · M MCP servers · D remove · Enter details'); expect(out).toContain('Marketplace'); expect(out).toContain('Summary'); @@ -137,6 +130,35 @@ describe('plugins selector dialogs', () => { expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' }); }); + it('ignores Left/Right arrows in the overview (no enter/exit by arrow)', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const picker = new PluginsOverviewSelectorComponent({ + plugins: [ + { + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '1.0.0', + enabled: true, + state: 'ok', + skillCount: 2, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'local-path', + }, + ], + colors: darkColors, + onSelect, + onCancel, + }); + + picker.handleInput(RIGHT); // must NOT open details + expect(onSelect).not.toHaveBeenCalled(); + picker.handleInput(LEFT); // must NOT cancel/exit + expect(onCancel).not.toHaveBeenCalled(); + }); + it('renders marketplace plugins separately from marketplace actions', () => { const onSelect = vi.fn(); const picker = new PluginMarketplaceSelectorComponent({ @@ -165,18 +187,72 @@ describe('plugins selector dialogs', () => { expect(out).toContain( `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, ); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); + expect(out).toContain('Enter install/update'); expect(out).toContain('Actions'); expect(out).toContain('Back to installed plugins'); - picker.handleInput(' '); + picker.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); + it('installs only on Enter, not Space, in the marketplace', () => { + const onSelect = vi.fn(); + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + description: 'Workflow skills', + source: 'https://example.com/superpowers.zip', + keywords: ['workflow'], + }, + ], + installedIds: new Set(), + source: '/tmp/marketplace.json', + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(' '); // Space must NOT install + expect(onSelect).not.toHaveBeenCalled(); + picker.handleInput('\r'); // Enter installs + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), + }); + }); + + it('ignores the Left arrow in the marketplace view (Esc returns instead)', () => { + const onCancel = vi.fn(); + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + description: 'Workflow skills', + source: 'https://example.com/superpowers.zip', + keywords: ['workflow'], + }, + ], + installedIds: new Set(), + source: '/tmp/marketplace.json', + colors: darkColors, + onSelect: vi.fn(), + onCancel, + }); + + picker.handleInput(LEFT); // must NOT return to the overview + expect(onCancel).not.toHaveBeenCalled(); + }); + it('issues install for installed marketplace entries (update path)', () => { const onSelect = vi.fn(); const picker = new PluginMarketplaceSelectorComponent({ @@ -331,8 +407,7 @@ describe('plugins selector dialogs', () => { const out = strip(raw); expect(out).toContain('MCP servers (1/1 enabled)'); expect(out).toContain('? data enabled'); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); + expect(out).toContain('Enter/Space enable/disable'); picker.handleInput(' '); @@ -403,8 +478,8 @@ describe('plugins selector dialogs', () => { picker.handleInput(''); const raw = renderRaw(picker); - expect(raw).toContain(primaryShortcut('Enter')); - expect(raw).toContain(primaryShortcut('Space')); + expect(strip(raw)).toContain('Enter/Space select'); + // The destructive option label keeps its danger styling (error + bold). expect(raw).toContain(dangerShortcut('Remove plugin')); picker.handleInput('\r'); diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts new file mode 100644 index 000000000..858289f42 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -0,0 +1,138 @@ +import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + ProviderManagerComponent, + type ProviderManagerOptions, +} from '#/tui/components/dialogs/provider-manager'; +import { darkColors } from '#/tui/theme/colors'; + +// Truecolor SGR fragments for the darkColors tokens we assert on +// (see theme/colors.ts). Forcing chalk.level below guarantees they appear. +const PRIMARY = '38;2;79;168;255'; // colors.primary #4FA8FF +const MUTED = '38;2;107;107;107'; // colors.textMuted #6B6B6B +const BOLD = '[1m'; +const ESC = String.fromCodePoint(27); + +const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); + +function rendered(component: ProviderManagerComponent, width = 120): string { + return component.render(width).join('\n').replaceAll(SGR, ''); +} + +function makeComponent(overrides: Partial<ProviderManagerOptions> = {}): ProviderManagerComponent { + return new ProviderManagerComponent({ + providers: {} as Record<string, ProviderConfig>, + colors: darkColors, + onAdd: vi.fn(), + onDeleteSource: vi.fn(), + onClose: vi.fn(), + ...overrides, + }); +} + +function addRowLine(component: ProviderManagerComponent, width = 120): string | undefined { + return component.render(width).find((line) => line.includes('Add New Platform')); +} + +describe('ProviderManagerComponent', () => { + let previousLevel: typeof chalk.level; + beforeAll(() => { + previousLevel = chalk.level; + chalk.level = 3; + }); + afterAll(() => { + chalk.level = previousLevel; + }); + + it('renders [ Add New Platform ] in the brand color, never muted, when not selected', () => { + // A configured provider occupies row 0 (selected); the add row sits below + // it and is therefore not the highlighted row. + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const line = addRowLine(component); + expect(line).toBeDefined(); + expect(line).toContain(PRIMARY); + expect(line).not.toContain(MUTED); + }); + + it('bolds [ Add New Platform ] when it is the selected row', () => { + // With no configured providers the synthetic add row is the only row, so it + // starts as the highlighted selection. + const component = makeComponent(); + const line = addRowLine(component); + expect(line).toBeDefined(); + expect(line).toContain(BOLD); + expect(line).toContain(PRIMARY); + }); + + it('marks the active provider with the shared "← current" marker, not a bullet', () => { + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const plain = component + .render(120) + .join('\n') + .replaceAll(/\[[0-9;]*m/g, ''); + expect(plain).toContain('← current'); + expect(plain).not.toContain('●'); + }); + + it('uses the same header shape as the model dialog (one top border, title, hint, no inner border)', () => { + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + }); + const lines = component.render(120).map((l) => l.replaceAll(SGR, '')); + const isBorder = (l: string | undefined): boolean => /^─+$/.test((l ?? '').trim()); + + const titleIdx = lines.findIndex((l) => l.includes('Providers')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + // The line directly under the title is the hint, never an inner border (the + // old `border · title · border` sandwich is gone). + expect(isBorder(lines[titleIdx + 1])).toBe(false); + expect(lines[titleIdx + 1]).toContain('navigate'); + expect(lines[titleIdx + 1]).toContain('Esc cancel'); + // Blank line separates the hint from the body, exactly like the model dialog. + expect(lines[titleIdx + 2]).toBe(''); + // Only the top and bottom full-width borders remain — two, not three. + expect(lines.filter(isBorder).length).toBe(2); + }); + + it('deletes the highlighted provider via the D key with a y/N confirm', () => { + const onDeleteSource = vi.fn(); + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + activeProviderId: 'acme', + onDeleteSource, + }); + component.handleInput('D'); + expect(rendered(component)).toContain('[y/N]'); + component.handleInput('y'); + expect(onDeleteSource).toHaveBeenCalledWith(['acme']); + }); + + it('closes on Esc', () => { + const onClose = vi.fn(); + const component = makeComponent({ + providers: { + acme: { baseUrl: 'https://acme.test' }, + } as unknown as Record<string, ProviderConfig>, + onClose, + }); + component.handleInput(ESC); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index fc79b48d6..5f5845316 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -136,7 +136,7 @@ describe('SessionPickerComponent', () => { expect(promptLine).not.toContain(longPrompt); }); - it('marks the current session with a (current) badge', () => { + it('marks the current session with a "← current" badge', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -165,8 +165,8 @@ describe('SessionPickerComponent', () => { const lines = component.render(120).map((line) => stripAnsi(line)); const currentLine = lines.find((line) => line.includes('this is current')); const otherLine = lines.find((line) => line.includes('not current')); - expect(currentLine).toContain('(current)'); - expect(otherLine).not.toContain('(current)'); + expect(currentLine).toContain('← current'); + expect(otherLine).not.toContain('← current'); }); it('places the relative time on the same line as the title, not right-aligned', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts new file mode 100644 index 000000000..d545103af --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -0,0 +1,112 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; +import { darkColors } from '#/tui/theme/colors'; + +const ESC = String.fromCodePoint(27); +const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); +const strip = (s: string): string => s.replaceAll(SGR, ''); +const TAB = '\t'; +const RIGHT = `${ESC}[C`; +// chalk.bgHex(colors.primary) → background truecolor for #4FA8FF. +const PRIMARY_BG = '48;2;79;168;255'; + +function model(displayName: string, provider: string): ModelAlias { + return { + provider, + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities: ['thinking'], + } as unknown as ModelAlias; +} + +function make(): { + component: TabbedModelSelectorComponent; + onSelect: ReturnType<typeof vi.fn>; +} { + const onSelect = vi.fn(); + const component = new TabbedModelSelectorComponent({ + models: { + k2: model('Kimi K2', 'managed:kimi-code'), + gpt: model('GPT-5', 'openai'), + }, + currentValue: 'k2', + currentThinking: false, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + component.focused = true; + return { component, onSelect }; +} + +describe('TabbedModelSelectorComponent', () => { + let previousLevel: typeof chalk.level; + beforeAll(() => { + previousLevel = chalk.level; + chalk.level = 3; + }); + afterAll(() => { + chalk.level = previousLevel; + }); + + it('renders an "All" + per-provider tab strip', () => { + const out = strip(make().component.render(120).join('\n')); + expect(out).toContain('All'); + expect(out).toContain('Kimi Code'); + expect(out).toContain('openai'); + }); + + it('highlights the active tab with a filled background (AskUserQuestion style)', () => { + // currentValue k2 → the active tab is "Kimi Code"; its cell carries the + // primary background SGR. + const raw = make().component.render(120).join('\n'); + expect(raw).toContain(PRIMARY_BG); + }); + + it('opens on the All tab by default (showing every provider\'s models)', () => { + const out = strip(make().component.render(120).join('\n')); + expect(out).toContain('Kimi K2'); + expect(out).toContain('GPT-5'); + }); + + it('cycles provider tabs with Tab', () => { + const { component } = make(); + // tabs = [All, Kimi Code, openai]; active starts on All. + // Two Tabs → openai, whose list shows GPT-5 and not Kimi K2. + component.handleInput(TAB); + component.handleInput(TAB); + const out = strip(component.render(120).join('\n')); + expect(out).toContain('GPT-5'); + expect(out).not.toContain('Kimi K2'); + }); + + it('forwards thinking toggle (←/→) and selection (Enter) to the active tab', () => { + const { component, onSelect } = make(); + component.handleInput(RIGHT); // toggle thinking on for k2 + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: true }); + }); + + it('frames the tab strip with a blank line above and below it', () => { + const lines = make().component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); // blank between hint and tabs + expect(stripIdx).toBe(hintIdx + 2); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + }); + + it('mentions the Tab provider switch first in the hint line', () => { + const lines = make().component.render(120).map(strip); + const hint = lines.find((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(hint).toBeDefined(); + expect(hint).toContain('Tab toggle provider'); + // It comes first, before the navigation hint. + expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 92e58f9ae..3b6ab6b62 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -2423,7 +2423,7 @@ describe('KimiTUI message flow', () => { ); }); const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput(' '); + picker.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); @@ -2460,7 +2460,7 @@ describe('KimiTUI message flow', () => { ); }); const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput(' '); + picker.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( @@ -2505,16 +2505,22 @@ describe('KimiTUI message flow', () => { const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; overview.handleInput(' '); + // Toggling refreshes the picker in place: it must not flash back to the + // editor between the keypress and the refreshed picker mounting. + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginsOverviewSelectorComponent, + ); + await vi.waitFor(() => { expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); }); + // The picker stays mounted the whole time (no editor flash), so wait for the + // refreshed render rather than for an instance swap. await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); + expect(refreshed).toContain('❯ Demo disabled require run /new to apply'); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ Demo disabled pending /new'); expect(out).not.toContain('Space enable'); expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); }); @@ -2607,7 +2613,7 @@ describe('KimiTUI message flow', () => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled pending /new'); + expect(out).toContain('❯ data disabled require run /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( 'Disabled MCP server data for kimi-datasource. Run /new to apply.', ); @@ -2693,15 +2699,16 @@ describe('KimiTUI message flow', () => { const picker = driver.state.editorContainer.children[0]; expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toContain('Kimi K2 (Kimi Code) ← current'); - expect(pickerOutput).toContain('❯ Kimi Turbo (Kimi Code)'); + expect(pickerOutput).toMatch(/Kimi K2\s+Kimi Code ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi Code/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); expect(filteredOutput).toContain('Search: tu'); - expect(filteredOutput).toContain('Kimi Turbo (Kimi Code)'); - expect(filteredOutput).not.toContain('Kimi K2 (Kimi Code)'); - (picker as TabbedModelSelectorComponent).handleInput('/'); + expect(filteredOutput).toContain('Kimi Turbo'); + expect(filteredOutput).not.toContain('Kimi K2'); + // Turbo is a thinking-capable model that is not the active one, so it + // defaults to thinking on — selecting it applies thinking without a toggle. (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { @@ -2778,8 +2785,8 @@ describe('KimiTUI message flow', () => { const output = stripSgr((picker as ModelSelectorComponent).render(120).join('\n')); expect(output).toContain('Search: tu'); - expect(output).toContain('Kimi Turbo (Kimi Code)'); - expect(output).not.toContain('Kimi Alpha (Kimi Code)'); + expect(output).toContain('Kimi Turbo'); + expect(output).not.toContain('Kimi Alpha'); (picker as ModelSelectorComponent).handleInput('\u001B'); (picker as ModelSelectorComponent).handleInput('\u001B');