mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
e0d28b4941
commit
90879f37af
29 changed files with 1273 additions and 445 deletions
178
.agents/skills/write-tui/DESIGN.md
Normal file
178
.agents/skills/write-tui/DESIGN.md
Normal 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 ③ hint(textMuted,紧贴标题,无键位高亮)
|
||||
④ 空行
|
||||
Search: gpt ⑤ 搜索行:仅在有 query 时出现(` Search: ` primary + query text)
|
||||
❯ GPT-5 openai ⑥ 列表项:指针 + 名称(左)+ 次要列(右,textMuted)
|
||||
Kimi K2 Kimi Code ← current 当前项行尾 ` ← current`(success)
|
||||
⑦ 空行
|
||||
▼ 3 more ⑧ 滚动 / 匹配指示:无 query 时 `▼ N more`,有 query 时 `x / y`
|
||||
───────────────────────────────────────── ⑨ 底部边框(primary,整宽 ─)
|
||||
```
|
||||
|
||||
硬性约定:
|
||||
|
||||
- **头部只有顶部一条 `─`**。标题下方紧跟 hint,**不得**再插一条 `─`。整个 dialog 全宽 `─` 仅 2 条(顶 + 底)。
|
||||
- **`(type to search)` 只出现在标题后缀**(可搜索且 query 为空时);hint 行**不再**重复出现「type to search」。
|
||||
- **`Search:` 行在空行之下、列表之上**,只在有 query 时渲染。
|
||||
- hint 紧贴标题(中间无空行);hint 与正文之间有 1 空行。
|
||||
- 每行最终经 `truncateToWidth(line, width)`,CJK / 窄终端不超宽。
|
||||
|
||||
## 4. hint 行与文案词汇(英文 UI)
|
||||
|
||||
每段 hint 形如「**键位 + 描述**」,段间用 ` · `(单空格中点)分隔。
|
||||
|
||||
| 动作 | 键位 token | 描述词 | 完整片段 |
|
||||
|---|---|---|---|
|
||||
| 移动 | `↑↓` | navigate | `↑↓ navigate` |
|
||||
| 翻页 | `←→` 或 `PgUp/PgDn` | page | `←→ page` |
|
||||
| 确认 / 选中 | `Enter` | select | `Enter select` |
|
||||
| 取消 / 关闭 | `Esc` | cancel | `Esc cancel` |
|
||||
| 删除 | `D` | delete | `D delete` |
|
||||
| 清空搜索 | `Backspace` | clear | `Backspace clear` |
|
||||
| 切 provider | `Tab` | toggle provider | `Tab toggle provider` |
|
||||
| 搜索(标题后缀) | 打字 | — | `(type to search)` |
|
||||
|
||||
- **键位 token 首字母大写**(`Enter` / `Esc` / `Tab` / `Backspace` / `D`),**描述词全小写**(navigate / select / cancel / page / delete / clear);方向符 `↑↓` / `←→` 原样。
|
||||
- 方向符统一 `↑↓`(不用 `▲/▼`)。
|
||||
- 「离开对话框」统一只说 `cancel`(不混用 close / back / exit / dismiss)。业务语义(如审批的 reject)例外。
|
||||
- hint 随状态精简:可搜索列表无 query 时,「type to search」在标题后缀已出现,hint 不重复;有 query 时 hint 追加 `Backspace clear`。
|
||||
|
||||
## 5. Tab 条(`/model` 的 provider 切换)
|
||||
|
||||
`tabbed-model-selector` 在 flat `model-selector` 外包一层 provider tab,样式对齐 **AskUserQuestion** 的 tab:
|
||||
|
||||
```
|
||||
Select a model (type to search)
|
||||
Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint 首项即 Tab 切换
|
||||
← 空行
|
||||
All Kimi Code openai ← tab 条:激活项填充背景(primary 底 + text 字 + bold),其余 textMuted
|
||||
← 空行
|
||||
❯ ...
|
||||
```
|
||||
|
||||
- tab 条位置:**在 hint 行下方**,且**上下各一空行**(与 hint、与列表都隔开)。
|
||||
- 激活 tab:`chalk.bgHex(colors.primary).hex(colors.text).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。
|
||||
- 第一个 tab 恒为 `All`(聚合所有 provider);**默认停在 `All`**。仅当显式传 `initialTabId`(如 `/provider` 新增完跳转)才停在指定 provider tab。
|
||||
- `Tab` / `Shift+Tab` 循环切换;hint 行首项即 `Tab toggle provider`。
|
||||
- 当前模型在所在 tab 内仍以 `❯` + ` ← current` 标记,切 tab 不丢失定位。
|
||||
|
||||
## 6. 键位
|
||||
|
||||
| 动作 | 键 | 判定方式 |
|
||||
|---|---|---|
|
||||
| 移动 | `↑` / `↓` | `matchesKey(data, Key.up/down)` |
|
||||
| 翻页 | `PgUp` / `PgDn` | `matchesKey(data, Key.pageUp/pageDown)` |
|
||||
| 确认 / 选中 | `Enter` | `matchesKey(data, Key.enter)` |
|
||||
| 取消 / 关闭 | `Esc` | `matchesKey(data, Key.escape)` |
|
||||
| 删除 | `D` | `printableChar(data) === 'D'`(也接受 `'d'`) |
|
||||
| 搜索 | 打字 | `printableChar(data)` |
|
||||
|
||||
- **字符比较必须经 `printableChar()`**(Kitty 协议),由 `printable-key-guard` 强制;功能键用 `matchesKey(data, Key.*)`。
|
||||
- **`Esc` 两段式**:有 query 时先清空 query(`list.clearQuery()`),无 query 时才 `onCancel()`。
|
||||
- `←` / `→` 不固定语义:无翻页结构的组件里承担「值切换」(如 `/model` 的 thinking on/off);`choice-picker` 这类无横向值的列表里用作翻页。**不要**在有 thinking 切换的组件里又拿 `←→` 翻页。
|
||||
- **删除键统一用字母 `D`**(`/provider`、`/plugins` 一致)。字母键要求该列表**不可 type-to-search**(否则会打进搜索框)——当前所有带删除动作的列表都不可搜索;若某列表既要搜索又要删除,删除须改用非打印键。
|
||||
|
||||
## 7. 开关列表与多选(toggle / multi-select)
|
||||
|
||||
适用于「每行可独立开 / 关」的列表(如 `/plugins` 的已装插件、MCP server 列表)。区别于单选(`Enter` 选中即提交并关闭),开关列表用 `Space` 就地切换每行状态,dialog 不关闭。
|
||||
|
||||
```
|
||||
Plugins
|
||||
↑↓ navigate · Space toggle · Enter details · Esc cancel
|
||||
← 空行
|
||||
Installed plugins (2) ← 分区标题(textStrong / 加粗)
|
||||
❯ Kimi Datasource enabled ← 选中行(❯ + primary+bold 名称)+ 状态标签(success)
|
||||
id kimi-datasource · 1 skill · MCP 1/1 · via code.kimi.com · official ← 次要信息行(textMuted,` · ` 分隔)
|
||||
Superpowers disabled ← 未选中行(text 名称)+ 关态标签(textDim)
|
||||
id superpowers · 14 skills · via code.kimi.com · curated
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- **`Space` 切换当前行状态**(开 ↔ 关),即时生效、dialog 保持打开;hint 含 `Space toggle`。
|
||||
- **状态标签**紧跟名称、空 2 格:开 ` enabled`(`success`)、关 ` disabled`(`textDim`)。其它语义(如 `installed`=success、`install…`=primary)按 `statusStyle` 同源处理。
|
||||
- `Enter` 在开关列表里另作他用(如「查看详情」`Enter details`),不承担 toggle。
|
||||
- 多套独立动作时(toggle / 详情 / 删除 / 进子菜单),hint 逐项列全,键位首字母大写:`Space toggle · Enter details · D remove`(参照第 4 节大小写规则)。
|
||||
- 行下可附 1 行次要信息(id / 数量 / 来源 / 信任级),`textMuted`、` · ` 分隔。
|
||||
|
||||
## 8. Thinking 控件(`/model` 专属)
|
||||
|
||||
列表下方展示当前选中模型的 thinking 三态,外观固定 `[ On ] Off` 段式:
|
||||
|
||||
- 标题:`Thinking (←→ to switch)`(仅 `toggle` 态显示括号提示);其余态只显示 `Thinking`。
|
||||
- `toggle`:`[ On ] Off` / `On [ Off ]`,激活段 `primary+bold`。
|
||||
- `always-on`:`[ Always on ]`。
|
||||
- `unsupported`:`[ Off ]` + `unsupported`(textMuted)。
|
||||
- `←` / `→` 翻转草稿;提交时经 `effectiveThinking()` 归一(always-on→true、unsupported→false)。
|
||||
|
||||
## 9. 输入框(多字段)
|
||||
|
||||
- 圆角盒 `╭ ╮ ╰ ╯`(`primary`)。
|
||||
- 字段切换:`Tab` / `Shift+Tab` / `↑` / `↓`。
|
||||
- `Enter`:非末段→推进到下一字段;末段→提交。
|
||||
- 取消:`Esc` / `Ctrl+C` / `Ctrl+D`。
|
||||
- footer 随焦点动态:非末段显示 `Enter next`,末段显示 `Enter submit`。
|
||||
- 必填校验按字段顺序定位(如 custom-registry:URL 空→定位 URL,token 空→定位 token),错误用对应的子提示态。
|
||||
|
||||
## 10. 共享组件(优先复用,不另起炉灶)
|
||||
|
||||
| 形态 | 组件 |
|
||||
|---|---|
|
||||
| 列表光标 / 搜索 / 翻页状态机 | `utils/searchable-list.ts` → `SearchableList` |
|
||||
| 分页视图 | `utils/paging.ts` → `pageView` |
|
||||
| Kitty 可打印字符 | `utils/printable-key.ts` → `printableChar` / `isPrintableChar`(含 guard) |
|
||||
| 选中指针 / 当前项标记 | `constant/symbols.ts` → `SELECT_POINTER` / `CURRENT_MARK` |
|
||||
|
||||
新列表组件**必须复用 `SearchableList`**(光标 / 搜索 / 翻页),并手工对齐本文件第 3–8 节的布局、键位、文案。
|
||||
|
||||
## 11. 新增 / 改造 dialog 自查清单
|
||||
|
||||
- [ ] 头部按第 3 节:顶部一条 `─`、标题(+`(type to search)` 后缀)、hint、空行、`Search:` 行、列表、底部一条 `─`;标题下**无**内层 `─`。
|
||||
- [ ] hint 整行 `textMuted`,**不**做键位高亮;键位首字母大写、描述词小写、` · ` 分隔。
|
||||
- [ ] 选中指针用 `SELECT_POINTER`,当前项用 `CURRENT_MARK`,未自造 `>` / `▶` / `→` / `● ` / `(current)`。
|
||||
- [ ] 颜色全部来自 `colors.<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 键行为)。
|
||||
83
.agents/skills/write-tui/SKILL.md
Normal file
83
.agents/skills/write-tui/SKILL.md
Normal 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).
|
||||
5
.changeset/unify-tui-dialog-ux.md
Normal file
5
.changeset/unify-tui-dialog-ux.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Unify the interaction and visuals across TUI dialogs and selectors.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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.')}`,
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' ')}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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('[B');
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue