feat(cli): unify TUI dialog interaction and visuals (#363)

* feat(cli): unify TUI dialog interaction and visuals

Align every list dialog and selector to a single spec: shared selection
pointer and current-item marker, single-border header with a (type to
search) title suffix, consistent search line, and a uniform keyboard-hint
vocabulary. Replace ad-hoc exit wording (close/back/exit/dismiss) with
cancel, hardcoded pointers with the shared constant, and ▲▼ with ↑↓.

Also add fuzzy search to /provider, restyle the /model provider tabs, and
require a token with multi-field navigation in the custom-registry import.

Introduce the write-tui skill (carrying the DESIGN.md spec) and refresh
the apps/kimi-code development guide to match the current TUI architecture.

* feat(cli): drop arrow-key navigation in the plugins selector

The plugins overview and its marketplace/MCP sub-views used Left/Right to
enter and exit details. Remove that hierarchy navigation: Enter opens a
detail, Esc returns, and the arrow keys no longer jump between levels.
Update the sub-view hints from `←/Esc cancel` to `Esc cancel`.

* feat(cli): install marketplace plugins on Enter only

The marketplace install action also fired on Space, which collides with
the Space-toggle convention used elsewhere. Bind install to Enter only and
update the hint to `Enter install/update`.

* feat(cli): reword the plugin inline change hint

The inline badge shown on a changed plugin row read `pending /new`, which
was cryptic. Reword it to `require run /new to apply`. Also switch the
marketplace-install message-flow tests to Enter, matching the Enter-only
install binding.

* fix(cli): stop the editor flash when toggling a plugin

Each plugin picker's onSelect unconditionally restored the editor before
the handler re-mounted the refreshed picker, so an in-place action like
Space-toggling a plugin flashed: picker → editor → picker. Drop the
pre-restore from the overview/marketplace/MCP onSelect callbacks and let
each handler branch mount its next view; the two branches that close to the
editor (show-list, info) restore it themselves.

* feat(cli): drop /provider search and delete with D

Remove the fuzzy filter from the provider manager: no query state, search
line, or type-to-search title suffix, and Esc closes directly. With no
type-to-search to clash with, bind delete to the D key (matching /plugins)
instead of Del/Ctrl+D. Update the write-tui spec to specify D as the
delete shortcut.

* fix(cli): default thinking-capable models to thinking on

The model selector seeded a single thinking draft from the global thinking
flag, so highlighting a thinking-capable model showed Off whenever the
active model had thinking off (e.g. a non-thinking current model). Compute
the draft per model instead: the active model keeps its live state, any
other thinking-capable model defaults to On, and a ←/→ toggle is remembered
per model.
This commit is contained in:
liruifengv 2026-06-03 19:31:07 +08:00 committed by GitHub
parent e0d28b4941
commit 90879f37af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1273 additions and 445 deletions

View file

@ -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.<token>)`。仓库 `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 ③ hinttextMuted紧贴标题无键位高亮
④ 空行
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-registryURL 空→定位 URLtoken 空→定位 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`**(光标 / 搜索 / 翻页),并手工对齐本文件第 38 节的布局、键位、文案。
## 11. 新增 / 改造 dialog 自查清单
- [ ] 头部按第 3 节:顶部一条 `─`、标题(+`(type to search)` 后缀、hint、空行、`Search:` 行、列表、底部一条 `─`;标题下**无**内层 `─`
- [ ] hint 整行 `textMuted`**不**做键位高亮;键位首字母大写、描述词小写、` · ` 分隔。
- [ ] 选中指针用 `SELECT_POINTER`,当前项用 `CURRENT_MARK`,未自造 `>` / `▶` / `→` / `● ` / `(current)`
- [ ] 颜色全部来自 `colors.<token>`,无 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 键行为)。

View file

@ -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.<token>)` for color (CI guard).

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Unify the interaction and visuals across TUI dialogs and selectors.

View file

@ -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.

View file

@ -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.<token>)(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

View file

@ -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';
}

View file

@ -222,8 +222,8 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
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,

View file

@ -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);

View file

@ -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);

View file

@ -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 } });

View file

@ -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.')}`,

View file

@ -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<string, ModelAlias>): 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<ModelChoice>;
private thinkingDraft: boolean;
/** Per-model thinking override set by ←/→; absent → the capability default. */
private readonly thinkingOverrides = new Map<string, boolean>();
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)}`;
}
}

View file

@ -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[] {

View file

@ -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);
}

View file

@ -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(' ')}`);
}

View file

@ -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: "<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);

View file

@ -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) {

View file

@ -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);

View file

@ -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

View file

@ -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) {

View file

@ -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';

View file

@ -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');
});
});

View file

@ -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' },
});
});
});

View file

@ -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);
}
}
});
});

View file

@ -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');

View file

@ -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);
});
});

View file

@ -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', () => {

View file

@ -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'));
});
});

View file

@ -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');