From 7cb4a23e01dfaf0e049891b90a27b36000714151 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 17:26:41 +0800 Subject: [PATCH 001/476] fix: truncate queued messages to a single line (#586) --- .changeset/truncate-queue-line.md | 5 +++ .../src/tui/components/panes/queue-pane.ts | 42 ++++++++++++++----- .../tui/components/panes/queue-pane.test.ts | 33 +++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 .changeset/truncate-queue-line.md diff --git a/.changeset/truncate-queue-line.md b/.changeset/truncate-queue-line.md new file mode 100644 index 000000000..1c437ed56 --- /dev/null +++ b/.changeset/truncate-queue-line.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Truncate queued message display to a single line with ellipsis when it exceeds terminal width. diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 852216263..17c2886a0 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,4 +1,4 @@ -import { Container, Text } from '@earendil-works/pi-tui'; +import { Container, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { SELECT_POINTER } from '../../constant/symbols'; @@ -13,25 +13,45 @@ export interface QueuePaneOptions { readonly canSteerImmediately: boolean; } +const ELLIPSIS = '…'; + export class QueuePaneComponent extends Container { + private readonly messages: readonly QueuedMessage[]; + private readonly colors: ColorPalette; + private readonly hint: string | undefined; + constructor(options: QueuePaneOptions) { super(); - - const accent = chalk.hex(options.colors.accent); - const dim = chalk.hex(options.colors.textDim); - - for (const item of options.messages) { - this.addChild(new Text(accent(` ${SELECT_POINTER} ${item.text}`), 0, 0)); - } + this.messages = options.messages; + this.colors = options.colors; if (options.messages.length > 0) { - const hint = + this.hint = options.isCompacting && !options.isStreaming ? ' ↑ to edit · will send after compaction' : !options.canSteerImmediately ? ' ↑ to edit · will send after current task' - : ' ↑ to edit · ctrl-s to steer immediately'; - this.addChild(new Text(dim(hint), 0, 0)); + : ' ↑ to edit · ctrl-s to steer immediately'; } } + + override render(width: number): string[] { + const accent = chalk.hex(this.colors.accent); + const dim = chalk.hex(this.colors.textDim); + const lines: string[] = []; + + for (const item of this.messages) { + const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); + const prefix = ` ${SELECT_POINTER} `; + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } + + if (this.hint !== undefined) { + lines.push(dim(truncateToWidth(this.hint, width, ELLIPSIS))); + } + + return lines; + } } diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index fbb5e4979..4abfdc606 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -55,4 +55,37 @@ describe('QueuePaneComponent', () => { expect(output).toContain('will send after current task'); expect(output).not.toContain('ctrl-s to steer immediately'); }); + + it('truncates long messages to a single line', () => { + const longText = 'a'.repeat(200); + const component = new QueuePaneComponent({ + colors: darkColors, + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: longText }], + }); + + const lines = component.render(30); + expect(lines).toHaveLength(2); // message + hint + const messageLine = stripAnsi(lines[0] as string); + expect(messageLine).not.toContain('a'.repeat(30)); + expect(messageLine.endsWith('…')).toBe(true); + }); + + it('collapses multiline text into a single line', () => { + const component = new QueuePaneComponent({ + colors: darkColors, + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'line one\nline two\nline three' }], + }); + + const lines = component.render(120); + expect(lines).toHaveLength(2); // message + hint + const messageLine = stripAnsi(lines[0] as string); + expect(messageLine).toContain('line one line two line three'); + expect(messageLine).not.toContain('\n'); + }); }); From d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 18:44:10 +0800 Subject: [PATCH 002/476] feat: add Claude Codex import skill (#582) --- .changeset/import-cc-codex.md | 6 + .../src/skill/builtin/import-from-cc-codex.md | 281 ++++++++++++++++++ .../src/skill/builtin/import-from-cc-codex.ts | 23 ++ .../agent-core/src/skill/builtin/index.ts | 3 + .../test/harness/skill-session.test.ts | 15 +- 5 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 .changeset/import-cc-codex.md create mode 100644 packages/agent-core/src/skill/builtin/import-from-cc-codex.md create mode 100644 packages/agent-core/src/skill/builtin/import-from-cc-codex.ts diff --git a/.changeset/import-cc-codex.md b/.changeset/import-cc-codex.md new file mode 100644 index 000000000..4ec1b2c8e --- /dev/null +++ b/.changeset/import-cc-codex.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. diff --git a/packages/agent-core/src/skill/builtin/import-from-cc-codex.md b/packages/agent-core/src/skill/builtin/import-from-cc-codex.md new file mode 100644 index 000000000..fae898468 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/import-from-cc-codex.md @@ -0,0 +1,281 @@ +--- +name: import-from-cc-codex +description: Import Claude Code and Codex instructions, skills, and MCP settings into Kimi Code. +disable-model-invocation: true +--- + +# Import from Claude Code and Codex + +The user invoked `/import-from-cc-codex` (or `/skill:import-from-cc-codex`). +Help them migrate selected local Claude Code and Codex assets into Kimi Code. +This skill is intentionally conservative: it imports only instructions, skills, +and MCP server declarations from `.claude` / `.codex` surfaces, with a user +preview before any write. + +## Non-negotiable rules + +- Do **not** migrate `.agents` content. Kimi Code already supports `.agents` + skills and AGENTS files by default. +- Do **not** migrate Claude custom commands (`.claude/commands/**`). They are + out of scope for this importer. +- Do **not** migrate credentials, OAuth tokens, sessions, history, logs, hooks, + plugins, plugin caches, output styles, or custom agents/subagents. +- Do **not** run or install anything from the source directories. +- Do **not** write anything until the user has chosen what to migrate, reviewed + the final preview, and explicitly confirmed applying it. +- Only write under Kimi Code targets: + - User-global: `$KIMI_CODE_HOME` if set, otherwise `~/.kimi-code`. + - Project instructions/skills: `/.kimi-code`, where the project + root is the nearest parent directory containing `.git`; if no `.git` exists, + use the current working directory. + - Project-local MCP: `/.kimi-code/mcp.json`, because Kimi reads the + current working directory's Kimi-specific MCP file, not every project-root + `.kimi-code/mcp.json` from subdirectories. +- Preserve existing Kimi files. Never overwrite existing skills or replace an + existing AGENTS.md / mcp.json wholesale. + +## Conversation flow + +### 1. Ask what to migrate first + +Before reading source files, ask the user which categories to migrate. Use +`AskUserQuestion` when available; otherwise ask in plain text and stop. Offer a +multi-select choice: + +- Instructions (`AGENTS.md` / `CLAUDE.md`) +- Skills +- MCP settings +- All of the above + +If the user already gave a preference in the invocation arguments, present it as +the default/recommended choice, but still ask for confirmation of the categories. +If the user dismisses or refuses the question, stop. + +### 2. Scan only the chosen categories + +Resolve paths explicitly; `~` is the real OS home, and Kimi home follows +`$KIMI_CODE_HOME` before `~/.kimi-code`. + +User-level sources: + +- Claude instructions: + - `~/.claude/AGENTS.md` + - `~/.claude/CLAUDE.md` +- Claude skills: + - `~/.claude/skills/` +- Claude MCP candidates: + - `~/.claude.json` only when MCP is selected. Claude Code stores user-level + MCP declarations there; do not read it for instruction/skill-only imports. +- Codex instructions: + - `~/.codex/AGENTS.md` + - `~/.codex/CLAUDE.md` if present +- Codex skills: + - `~/.codex/skills/` +- Codex MCP candidates: + - `~/.codex/config.toml` + +Project-level sources, rooted at the project root: + +- Claude instructions: + - `/.claude/AGENTS.md` + - `/.claude/CLAUDE.md` +- Claude skills: + - `/.claude/skills/` +- Codex instructions: + - `/.codex/AGENTS.md` + - `/.codex/CLAUDE.md` if present +- Codex skills: + - `/.codex/skills/` +- Codex MCP candidates: + - `/.codex/config.toml` + +Do not scan project-root `AGENTS.md`, project-root `CLAUDE.md`, `.agents/**`, or +project-root `.mcp.json` in this skill. `AGENTS.md` and `.agents/**` are already +Kimi-readable, and project-root `.mcp.json` is already read by Kimi as a +Claude-compatible MCP file. + +### 3. Build an import plan + +Create a plan with three sections: instructions, skills, and MCP. Include exact +source and target paths. + +#### Instructions plan + +Map user-level instruction sources to: + +- `$KIMI_CODE_HOME/AGENTS.md`, or `~/.kimi-code/AGENTS.md` if the env var is not + set. + +Map project-level instruction sources to: + +- `/.kimi-code/AGENTS.md` + +Append imported instruction content as marked blocks. Do not duplicate a block +that already exists in the target file. + +Use this marker shape: + +```md + + + + + +``` + +For Codex, use `Imported from Codex` / `End imported from Codex`. + +If a source file is empty, skip it and report it as skipped. If the target exists +and cannot be read as UTF-8 text, stop before writing and report the blocker. + +#### Skills plan + +Map user-level skill sources to: + +- `$KIMI_CODE_HOME/skills/`, or `~/.kimi-code/skills/` if the env var is not set. + +Map project-level skill sources to: + +- `/.kimi-code/skills/` + +Recognize these skill shapes under `.claude/skills/` or `.codex/skills/`: + +- Directory bundle: `/SKILL.md` +- Flat markdown skill: `.md` + +Copy the entire directory bundle or flat markdown file. Preserve supporting +files inside bundles. Do not copy hidden directories, `node_modules`, caches, or +plugin-managed folders. + +Before planning a copy: + +- Read a bundle's `SKILL.md` enough to verify that directory skills have + frontmatter with non-empty `name` and `description`, because Kimi requires + those fields for directory skills. +- If the target top-level entry already exists, skip it; do not overwrite. +- If two source entries would write the same target path, keep the first one in + this order and report the later one as skipped: + 1. project Claude + 2. project Codex + 3. user Claude + 4. user Codex +- Warn when a source skill uses Claude/Codex-specific fields or syntax that Kimi + may not interpret the same way, such as `allowed-tools`, `disallowed-tools`, + `context: fork`, `agent`, `hooks`, `paths`, dynamic shell injection with + ``!`command` ``, or `agents/openai.yaml`. Preserve the file; do not rewrite it + unless the user explicitly asks. + +Do not convert `.claude/commands/*.md`. Commands are out of scope. + +#### MCP plan + +Do not edit `mcp.json` directly in this import skill. Prepare MCP entries for +manual follow-up with `/mcp-config`; that built-in skill is user-invocable only, +so you must not try to call it through the `Skill` tool. + +For the preview, collect MCP candidates and normalize them into Kimi's MCP shape +when possible: + +```json +{ + "mcpServers": { + "name": { + "command": "...", + "args": ["..."], + "env": { "KEY": "VALUE" } + } + } +} +``` + +Claude user MCP: + +- Read `~/.claude.json` only if MCP was selected. +- Look for a top-level `mcpServers` object. +- Keep stdio entries with `command`; keep HTTP entries with `url`. +- Preserve `args`, `env`, `cwd`, `enabled`, `enabledTools`, `disabledTools`, + `startupTimeoutMs`, `toolTimeoutMs`, `headers`, and `bearerTokenEnvVar` when + present and valid. +- Drop unsupported or malformed entries and report why. + +Codex MCP: + +- Read selected `config.toml` files only if MCP was selected. +- Look for `[mcp_servers.]` tables. +- Map Codex fields to Kimi fields: + - `command` -> `command` + - `args` -> `args` + - `env` -> `env` + - `cwd` -> `cwd` + - `url` -> `url` + - `bearer_token_env_var` -> `bearerTokenEnvVar` + - `enabled` -> `enabled` + - `enabled_tools` -> `enabledTools` + - `disabled_tools` -> `disabledTools` + - `startup_timeout_sec` -> `startupTimeoutMs` in milliseconds + - `tool_timeout_sec` -> `toolTimeoutMs` in milliseconds + - `http_headers` -> `headers` +- Drop unsupported Codex-only fields and report them, especially `required`, + `default_tools_approval_mode`, `tools..approval_mode`, + `env_vars`, `env_http_headers`, and `experimental_environment`. +- Do not import project-root `.mcp.json`; Kimi already reads it. + +For each MCP candidate, choose the target scope in the preview: + +- User-level source -> user-global MCP target (`$KIMI_CODE_HOME/mcp.json` or + `~/.kimi-code/mcp.json`). +- Project-level source -> project-local Kimi MCP target (`/.kimi-code/mcp.json`). If `` is not the project root, call this out in the preview so the user understands when Kimi will load it. + +Warn that stdio MCP entries spawn commands at session start, and the user should +only import MCP servers they trust. Warn if an MCP entry contains apparent +literal secrets in `env`, `headers`, or token-like fields; prefer env-var +references. + +After the user confirms applying the final preview, do not write MCP config and +do not invoke `mcp-config` programmatically. Instead, finish the non-MCP writes +and show a copy-pasteable manual follow-up for the user, including: + +- the `/mcp-config` command they should run, +- target scope and target path, +- the normalized JSON entry or entries to add, +- collision policy: keep existing Kimi entries on name conflict, +- the reminder that unrelated entries must be preserved. + +Make it clear that MCP import is pending until the user manually runs +`/mcp-config` with the prepared entries. + +### 4. Show the final preview and stop + +After scanning, show a concise final preview grouped by target file/directory: + +- Will append instruction blocks +- Will copy skill bundles/files +- Will leave these MCP entries pending for a manual `/mcp-config` follow-up +- Already present / skipped +- Warnings and blockers + +Then ask for explicit confirmation before writing. Use a clear choice such as: + +- Apply import +- Cancel + +If there are blockers, do not offer apply; explain what must be fixed first. + +### 5. Apply only after confirmation + +When the user confirms: + +- Create target directories with private permissions where possible. +- Append instruction blocks without duplicating existing imported source blocks. +- Copy skills without overwriting existing target entries. +- Do not write MCP entries. Show the prepared `/mcp-config` follow-up command + and mark MCP import as pending user action. +- Report exactly what changed and what was skipped. +- Tell the user to start a new session (for example `/new`) or restart Kimi Code + for newly imported skills, instructions, and MCP servers to be picked up. + +## Output style + +Be brief but precise. Use absolute paths in previews and summaries. Prefer a +small table or bullet list over long prose. If nothing is found for a selected +category, say so and do not treat it as an error. diff --git a/packages/agent-core/src/skill/builtin/import-from-cc-codex.ts b/packages/agent-core/src/skill/builtin/import-from-cc-codex.ts new file mode 100644 index 000000000..bdc371100 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/import-from-cc-codex.ts @@ -0,0 +1,23 @@ +import { parseSkillText } from '../parser'; +import type { SkillDefinition } from '../types'; +import IMPORT_FROM_CC_CODEX_BODY from './import-from-cc-codex.md'; + +const PSEUDO_PATH = 'builtin://import-from-cc-codex'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/import-from-cc-codex.md', + skillDirName: 'import-from-cc-codex', + source: 'builtin', + text: IMPORT_FROM_CC_CODEX_BODY, +}); + +export const IMPORT_FROM_CC_CODEX_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + disableModelInvocation: true, + }, +}; diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index 1f928ee84..cf7fc0804 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -1,4 +1,5 @@ import type { SkillRegistry } from '../registry'; +import { IMPORT_FROM_CC_CODEX_SKILL } from './import-from-cc-codex'; import { MCP_CONFIG_SKILL } from './mcp-config'; import { SUB_SKILL_CONSOLIDATE, @@ -9,6 +10,7 @@ import { UPDATE_CONFIG_SKILL } from './update-config'; export function registerBuiltinSkills(registry: SkillRegistry): void { registry.registerBuiltinSkill(MCP_CONFIG_SKILL); + registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL); registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL); registry.registerBuiltinSkill(SUB_SKILL_PARENT); registry.registerBuiltinSkill(SUB_SKILL_REVIEW); @@ -16,6 +18,7 @@ export function registerBuiltinSkills(registry: SkillRegistry): void { } export { + IMPORT_FROM_CC_CODEX_SKILL, MCP_CONFIG_SKILL, SUB_SKILL_CONSOLIDATE, SUB_SKILL_PARENT, diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 75cd553cd..c14336b1f 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -89,15 +89,24 @@ describe('HarnessAPI session skills', () => { const created = await rpc.createSession({ id: 'ses_builtin_skill_list', workDir }); const skills = await rpc.listSkills({ sessionId: created.id }); - const listed = skills.find((skill) => skill.name === 'mcp-config'); + const mcpConfig = skills.find((skill) => skill.name === 'mcp-config'); + const importer = skills.find((skill) => skill.name === 'import-from-cc-codex'); - expect(listed).toMatchObject({ + expect(mcpConfig).toMatchObject({ name: 'mcp-config', description: 'Configure MCP servers and handle MCP OAuth login.', source: 'builtin', }); - expect(listed?.path).toBe('builtin://mcp-config'); + expect(mcpConfig?.path).toBe('builtin://mcp-config'); + expect(importer).toMatchObject({ + name: 'import-from-cc-codex', + description: 'Import Claude Code and Codex instructions, skills, and MCP settings into Kimi Code.', + source: 'builtin', + disableModelInvocation: true, + }); + expect(importer?.path).toBe('builtin://import-from-cc-codex'); expect(JSON.stringify(skills)).not.toContain('Your tool list contains one synthetic tool'); + expect(JSON.stringify(skills)).not.toContain('Do not migrate Claude custom commands'); }); it('resolves user brand skills from the kimi home, not the OS home', async () => { From f863127ab7e8b8e2e9af11c54694c08900e3103a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 18:55:15 +0800 Subject: [PATCH 003/476] feat: custom color themes (#484) * Refactor theme * custom theme support * docs: add custom themes guide Document the custom theme file location, the color token reference, selecting a theme via /theme and tui.toml, and fallback behavior. Link it from the customization sidebar and the tui.toml theme field. * feat(skill): add built-in custom-theme skill Guides the model (or a manual /custom-theme run) to author a theme JSON in ~/.kimi-code/themes/: docs token reference, deliberate color choices, hex validation, and how to apply via /theme or /reload-tui. Note in the write-tui skill to keep the token set in sync across colors.ts, the schema, the docs, and this skill. Enrich the custom-theme changeset to cover all three usage paths. * chore: remove theme research report * fix(tui): resolve lint errors after main merge Remove unused chalk/currentTheme/ResolvedTheme imports left by the theme refactor; break the theme <-> pi-tui-theme import cycle by dropping the markdown/editor theme getters from the Theme class (consumers call createMarkdownTheme directly); fix unused vars/params, a floating promise, and a redundant union type. * fix(tui): address custom theme review feedback - await applyTheme before refreshing terminal theme tracking, so switching to "auto" installs the watcher against the new state - invalidate the transcript on automatic (terminal-driven) theme changes so already-rendered entries repaint - rebuild UsagePanel bodies on invalidate (previously a no-op); /usage, /status, /mcp and /plugins now repaint on a theme switch - repaint the compaction header on invalidate - validate a custom theme before applying it from the /theme picker - hide reserved dark/light/auto names from the custom theme list - escape the theme name when writing tui.toml - stop the custom theme loader writing warnings to the raw terminal - remove a stray hello.ts * refactor(tui): polish custom theme feature - footer and todo-panel read the currentTheme singleton directly at render time instead of caching a palette copy; drop their setColors methods and the manual setColors calls on every theme change - support "base": "dark" | "light" in custom theme files so a partial light theme inherits the light palette for unspecified tokens - reconcile the docs and the custom-theme skill with the silent invalid-color fallback (no terminal warning) * refactor(tui): live-repaint the agent swarm progress panel Read the currentTheme palette through a getter instead of caching it at construction time, so the swarm progress panel recolors on a theme switch like the rest of the transcript. Drops the now-unused `colors` option. * chore: remove plan.md * docs: update custom theme guide * docs: document custom theme skill command * fix(skill): make custom theme user-triggered only --- .agents/skills/write-tui/SKILL.md | 2 + .changeset/custom-theme-support.md | 5 + apps/kimi-code/package.json | 1 + apps/kimi-code/src/cli/run-shell.ts | 9 +- .../src/migration/migration-screen.ts | 9 +- apps/kimi-code/src/tui/commands/config.ts | 52 +++-- apps/kimi-code/src/tui/commands/dispatch.ts | 6 +- apps/kimi-code/src/tui/commands/goal.ts | 9 +- apps/kimi-code/src/tui/commands/info.ts | 24 +-- apps/kimi-code/src/tui/commands/plugins.ts | 21 +- apps/kimi-code/src/tui/commands/prompts.ts | 7 +- apps/kimi-code/src/tui/commands/provider.ts | 7 +- apps/kimi-code/src/tui/commands/reload.ts | 19 +- apps/kimi-code/src/tui/commands/swarm.ts | 3 +- apps/kimi-code/src/tui/commands/undo.ts | 2 +- .../tui/components/chrome/device-code-box.ts | 20 +- .../src/tui/components/chrome/footer.ts | 17 +- .../src/tui/components/chrome/todo-panel.ts | 12 +- .../src/tui/components/chrome/welcome.ts | 18 +- .../dialogs/api-key-input-dialog.ts | 14 +- .../tui/components/dialogs/approval-panel.ts | 38 ++-- .../components/dialogs/approval-preview.ts | 57 +++-- .../tui/components/dialogs/choice-picker.ts | 45 ++-- .../src/tui/components/dialogs/compaction.ts | 48 +++-- .../dialogs/custom-registry-import.ts | 25 +-- .../tui/components/dialogs/editor-selector.ts | 4 - .../dialogs/experiments-selector.ts | 49 ++--- .../dialogs/feedback-input-dialog.ts | 16 +- .../components/dialogs/goal-queue-manager.ts | 36 ++-- .../dialogs/goal-start-permission-prompt.ts | 4 - .../src/tui/components/dialogs/help-panel.ts | 22 +- .../tui/components/dialogs/model-selector.ts | 41 ++-- .../components/dialogs/permission-selector.ts | 4 - .../components/dialogs/platform-selector.ts | 4 - .../components/dialogs/plugins-selector.ts | 115 +++++----- .../components/dialogs/provider-manager.ts | 43 ++-- .../tui/components/dialogs/question-dialog.ts | 47 ++-- .../tui/components/dialogs/session-picker.ts | 54 +++-- .../components/dialogs/settings-selector.ts | 4 - .../dialogs/start-permission-prompt.ts | 33 ++- .../dialogs/swarm-start-permission-prompt.ts | 4 - .../dialogs/tabbed-model-selector.ts | 15 +- .../components/dialogs/task-output-viewer.ts | 42 ++-- .../tui/components/dialogs/tasks-browser.ts | 102 +++++---- .../tui/components/dialogs/theme-selector.ts | 23 +- .../dialogs/update-preference-selector.ts | 4 - .../tui/components/editor/custom-editor.ts | 30 +-- .../src/tui/components/media/diff-preview.ts | 23 +- .../tui/components/media/image-thumbnail.ts | 33 ++- .../tui/components/messages/agent-group.ts | 50 +++-- .../messages/agent-swarm-progress.ts | 9 +- .../components/messages/assistant-message.ts | 26 ++- .../messages/background-agent-status.ts | 21 +- .../tui/components/messages/cron-message.ts | 20 +- .../tui/components/messages/goal-markers.ts | 42 ++-- .../src/tui/components/messages/goal-panel.ts | 67 +++--- .../components/messages/mcp-status-panel.ts | 23 +- .../messages/plugins-status-panel.ts | 35 ++- .../src/tui/components/messages/read-group.ts | 45 ++-- .../components/messages/shell-execution.ts | 10 +- .../components/messages/skill-activation.ts | 34 ++- .../tui/components/messages/status-message.ts | 50 ++++- .../tui/components/messages/status-panel.ts | 20 +- .../tui/components/messages/swarm-markers.ts | 14 +- .../src/tui/components/messages/thinking.ts | 21 +- .../src/tui/components/messages/tool-call.ts | 204 ++++++++---------- .../messages/tool-renderers/goal.ts | 22 +- .../messages/tool-renderers/truncated.ts | 17 +- .../messages/tool-renderers/types.ts | 2 - .../tui/components/messages/usage-panel.ts | 62 +++--- .../tui/components/messages/user-message.ts | 21 +- .../src/tui/components/panes/btw-panel.ts | 23 +- .../src/tui/components/panes/queue-pane.ts | 10 +- apps/kimi-code/src/tui/config.ts | 4 +- .../src/tui/controllers/btw-panel.ts | 4 +- .../tui/controllers/session-event-handler.ts | 41 ++-- .../src/tui/controllers/streaming-ui.ts | 16 +- .../tui/controllers/subagent-event-handler.ts | 1 - .../src/tui/controllers/tasks-browser.ts | 8 +- apps/kimi-code/src/tui/easter-eggs/dance.ts | 15 +- apps/kimi-code/src/tui/kimi-tui.ts | 131 +++++------ apps/kimi-code/src/tui/theme/bundle.ts | 27 --- apps/kimi-code/src/tui/theme/colors.ts | 173 +++++++-------- .../src/tui/theme/custom-theme-loader.ts | 97 +++++++++ apps/kimi-code/src/tui/theme/index.ts | 70 +++--- apps/kimi-code/src/tui/theme/pi-tui-theme.ts | 52 ++--- apps/kimi-code/src/tui/theme/styles.ts | 66 ------ .../kimi-code/src/tui/theme/theme-schema.json | 58 +++++ apps/kimi-code/src/tui/theme/theme.ts | 93 ++++++++ apps/kimi-code/src/tui/tui-state.ts | 14 +- apps/kimi-code/src/tui/types.ts | 8 +- apps/kimi-code/test/cli/doctor.test.ts | 8 +- apps/kimi-code/test/cli/run-shell.test.ts | 2 - .../test/migration/migration-screen.test.ts | 23 -- apps/kimi-code/test/tui/activity-pane.test.ts | 1 - .../test/tui/commands/experiments.test.ts | 4 +- apps/kimi-code/test/tui/commands/goal.test.ts | 4 +- .../test/tui/commands/reload.test.ts | 31 ++- .../kimi-code/test/tui/commands/swarm.test.ts | 4 +- .../tui/commands/update-preferences.test.ts | 2 +- .../components/chrome/device-code-box.test.ts | 3 - .../test/tui/components/chrome/footer.test.ts | 20 +- .../tui/components/chrome/welcome.test.ts | 8 +- .../components/dialogs/approval-panel.test.ts | 17 +- .../dialogs/approval-preview.test.ts | 5 - .../components/dialogs/choice-picker.test.ts | 9 - .../tui/components/dialogs/compaction.test.ts | 41 +++- .../dialogs/custom-registry-import.test.ts | 1 - .../dialogs/experiments-selector.test.ts | 6 +- .../dialogs/feedback-input-dialog.test.ts | 2 +- .../dialogs/goal-queue-manager.test.ts | 15 -- .../components/dialogs/model-selector.test.ts | 9 - .../dialogs/plugins-selector.test.ts | 13 -- .../dialogs/provider-manager.test.ts | 1 - .../dialogs/question-dialog.test.ts | 15 +- .../components/dialogs/session-picker.test.ts | 11 - .../dialogs/tabbed-model-selector.test.ts | 1 - .../components/editor/custom-editor.test.ts | 3 +- .../components/editor/slash-highlight.test.ts | 20 +- .../tui/components/media/diff-preview.test.ts | 20 +- .../messages/agent-swarm-progress.test.ts | 28 ++- .../messages/assistant-message.test.ts | 7 +- .../messages/background-agent-status.test.ts | 40 ++-- .../components/messages/goal-markers.test.ts | 18 +- .../components/messages/goal-panel.test.ts | 12 +- .../messages/mcp-status-panel.test.ts | 3 - .../tui/components/messages/notice.test.ts | 2 - .../messages/shell-execution.test.ts | 12 +- .../components/messages/status-panel.test.ts | 3 - .../tui/components/messages/thinking.test.ts | 11 +- .../tui/components/messages/tool-call.test.ts | 82 ++----- .../messages/tool-renderers/truncated.test.ts | 6 +- .../components/messages/usage-panel.test.ts | 29 ++- .../components/messages/user-message.test.ts | 1 - .../panels/footer-bg-agents.test.ts | 17 +- .../components/panels/footer-context.test.ts | 18 +- .../panels/footer-goal-badge.test.ts | 17 +- .../tui/components/panels/help-panel.test.ts | 7 - .../tui/components/panels/plan-box.test.ts | 5 +- .../tui/components/panels/todo-panel.test.ts | 15 +- .../tui/components/panes/queue-pane.test.ts | 6 - apps/kimi-code/test/tui/config.test.ts | 15 ++ .../session-event-handler-goal-queue.test.ts | 4 +- .../test/tui/create-tui-state.test.ts | 3 +- .../test/tui/easter-eggs/dance.test.ts | 4 +- .../test/tui/kimi-tui-message-flow.test.ts | 1 - .../test/tui/kimi-tui-startup.test.ts | 9 +- .../kimi-code/test/tui/message-replay.test.ts | 1 - .../test/tui/signal-handlers.test.ts | 1 - .../test/tui/task-output-viewer.test.ts | 4 - apps/kimi-code/test/tui/tasks-browser.test.ts | 1 - .../kimi-code/test/tui/terminal-theme.test.ts | 28 +-- .../tui/theme/custom-theme-loader.test.ts | 86 ++++++++ docs/.vitepress/config.ts | 2 + docs/en/configuration/config-files.md | 4 +- docs/en/customization/themes.md | 111 ++++++++++ docs/en/reference/slash-commands.md | 2 +- docs/zh/configuration/config-files.md | 4 +- docs/zh/customization/themes.md | 111 ++++++++++ docs/zh/reference/slash-commands.md | 2 +- .../src/skill/builtin/custom-theme.md | 100 +++++++++ .../src/skill/builtin/custom-theme.ts | 23 ++ .../agent-core/src/skill/builtin/index.ts | 3 + .../test/skill/builtin-custom-theme.test.ts | 59 +++++ 164 files changed, 2277 insertions(+), 1887 deletions(-) create mode 100644 .changeset/custom-theme-support.md delete mode 100644 apps/kimi-code/src/tui/theme/bundle.ts create mode 100644 apps/kimi-code/src/tui/theme/custom-theme-loader.ts delete mode 100644 apps/kimi-code/src/tui/theme/styles.ts create mode 100644 apps/kimi-code/src/tui/theme/theme-schema.json create mode 100644 apps/kimi-code/src/tui/theme/theme.ts create mode 100644 apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts create mode 100644 docs/en/customization/themes.md create mode 100644 docs/zh/customization/themes.md create mode 100644 packages/agent-core/src/skill/builtin/custom-theme.md create mode 100644 packages/agent-core/src/skill/builtin/custom-theme.ts create mode 100644 packages/agent-core/test/skill/builtin-custom-theme.test.ts diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md index be3885a31..3952b84b5 100644 --- a/.agents/skills/write-tui/SKILL.md +++ b/.agents/skills/write-tui/SKILL.md @@ -68,6 +68,8 @@ Themes are managed centrally under `src/tui/theme/`: - `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`. - `index.ts` / `detect.ts` — theme type and auto/dark/light resolution. +> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/kimi-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`). + Apply / switch flow: - UI entry: `ThemeSelectorComponent` → `handleThemeCommand` → `applyThemeChoice`. diff --git a/.changeset/custom-theme-support.md b/.changeset/custom-theme-support.md new file mode 100644 index 000000000..a8044e2e7 --- /dev/null +++ b/.changeset/custom-theme-support.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e3c9a8e92..e7c921521 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -33,6 +33,7 @@ ], "type": "module", "imports": { + "#/tui/theme": "./src/tui/theme/index.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts" diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 21e23602b..b3443ee99 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -22,7 +22,7 @@ import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; -import { detectTerminalTheme } from '#/tui/theme/detect'; +import { currentTheme, getColorPalette } from '#/tui/theme'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; @@ -45,9 +45,9 @@ export async function runShell( configWarning = error.message; } - // Resolve `theme = "auto"` against the live terminal once, before pi-tui - // grabs stdin. Explicit `dark` / `light` skip detection. - const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme; + // Initialise the global Theme singleton before pi-tui grabs stdin. + const palette = await getColorPalette(tuiConfig.theme); + currentTheme.setPalette(palette); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -98,7 +98,6 @@ export async function runShell( version, workDir, startupNotice: configWarning, - resolvedTheme, migrationPlan, migrateOnly: runOptions.migrateOnly, }); diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index 6d1f89e18..b1ebfecfd 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -15,6 +15,7 @@ import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@ea import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { resolveMigrationScope, runMigration as realRunMigration, @@ -46,7 +47,7 @@ export interface MigrationScreenOptions { readonly plan: MigrationPlan; readonly sourceHome: string; readonly targetHome: string; - readonly colors: ColorPalette; + readonly colors?: ColorPalette; /** Called once the screen is finished; the host then restores the editor. */ readonly onComplete: (result: MigrationScreenResult) => void; /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ @@ -278,7 +279,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderResult(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; if (this.migrationFailed) { lines.push(chalk.hex(colors.error).bold(' Migration failed')); @@ -428,7 +429,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderProgress(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), @@ -458,7 +459,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderAsk(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const step = this.currentStep(); const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 3e652217e..d95c02e35 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -16,9 +16,9 @@ import { SettingsSelectorComponent, type SettingsSelection } from '../components import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { saveTuiConfig } from '../config'; -import type { Theme } from '../theme'; +import type { ThemeName } from '#/tui/theme'; +import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; -import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; @@ -186,9 +186,12 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): showThemePicker(host); return; } - if (!isTheme(theme)) { - host.showError(`Unknown theme: ${theme}`); - return; + if (!isBuiltInTheme(theme)) { + const custom = await loadCustomThemeMerged(theme); + if (custom === null) { + host.showError(`Unknown theme: ${theme}`); + return; + } } await applyThemeChoice(host, theme); } @@ -215,7 +218,6 @@ function showEditorPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new EditorSelectorComponent({ currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyEditorChoice(host, value); @@ -245,7 +247,7 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise } catch (error) { host.showStatus( `Failed to save editor: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } @@ -273,7 +275,6 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentValue: host.state.appState.model, selectedValue, currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking); @@ -338,7 +339,7 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin : persisted ? `Saved ${alias} with thinking ${level} as default.` : `Already using ${alias} with thinking ${level}.`; - host.showStatus(status, host.state.theme.colors.success); + host.showStatus(status, 'success'); } async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { @@ -357,7 +358,6 @@ function showThemePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new ThemeSelectorComponent({ currentValue: host.state.appState.theme, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyThemeChoice(host, value); @@ -369,13 +369,24 @@ function showThemePicker(host: SlashCommandHost): void { ); } -async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { +async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise { if (theme === host.state.appState.theme) { if (theme === 'auto') host.refreshTerminalThemeTracking(); host.showStatus(`Theme unchanged: "${theme}".`); return; } + // Validate custom themes up front so a missing / malformed file reports an + // error instead of silently persisting a name that resolves to the dark + // fallback. + if (!isBuiltInTheme(theme)) { + const palette = await loadCustomThemeMerged(theme); + if (palette === null) { + host.showStatus(`Theme "${theme}" could not be loaded.`, 'error'); + return; + } + } + try { await saveTuiConfig({ theme, @@ -386,13 +397,15 @@ async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { host.restoreEditor(); void applyPermissionChoice(host, value); @@ -419,7 +431,6 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new UpdatePreferenceSelectorComponent({ currentValue: host.state.appState.upgrade.autoInstall, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyUpdatePreferenceChoice(host, value); @@ -449,7 +460,7 @@ export async function applyExperimentalFeatureChanges( if (changes.length === 0) { host.showStatus( 'No experimental feature changes to apply.', - host.state.theme.colors.textMuted, + 'textMuted', ); return; } @@ -472,7 +483,7 @@ export async function applyExperimentalFeatureChanges( 'Experimental features updated. Session reloaded.', ); } else { - host.showStatus('Experimental features updated.', host.state.theme.colors.success); + host.showStatus('Experimental features updated.', 'success'); } host.track('experimental_features_apply', { changed: changes.length }); } catch (error) { @@ -487,7 +498,6 @@ function mountExperimentsPanel( host.mountEditorReplacement( new ExperimentsSelectorComponent({ features, - colors: host.state.theme.colors, onApply: (changes) => { void applyExperimentalFeatureChanges(host, changes); }, @@ -504,7 +514,6 @@ type UpdatePreferenceHost = { SlashCommandHost['state']['appState'], 'theme' | 'editorCommand' | 'notifications' | 'upgrade' >; - readonly theme: Pick; }; setAppState(patch: Pick): void; showStatus(msg: string, color?: string): void; @@ -531,7 +540,7 @@ export async function applyUpdatePreferenceChoice( } catch (error) { host.showStatus( `Failed to save automatic update setting: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } @@ -562,7 +571,6 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod export function showSettingsSelector(host: SlashCommandHost): void { host.mountEditorReplacement( new SettingsSelectorComponent({ - colors: host.state.theme.colors, onSelect: (value) => { handleSettingsSelection(host, value); }, diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b3e4593ac..1a50f510b 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -2,7 +2,7 @@ import type { Component, Focusable } from '@earendil-works/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Theme } from '../theme'; +import type { ColorToken, ThemeName } from '#/tui/theme'; import type { ResolvedTheme } from '../theme/colors'; import { LLM_NOT_SET_MESSAGE, @@ -107,7 +107,7 @@ export interface SlashCommandHost { setAppState(patch: Partial): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; track(event: string, props?: Record): void; mountEditorReplacement(panel: Component & Focusable): void; @@ -130,7 +130,7 @@ export interface SlashCommandHost { showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme - applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise; refreshTerminalThemeTracking(): void; // Dispatch diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index b2b83e81e..3dca323d7 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -206,7 +206,7 @@ async function queueNextGoal( host.track('goal_queue_append'); if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); host.state.transcriptContainer.addChild( - new UpcomingGoalAddedMessageComponent(host.state.theme.colors), + new UpcomingGoalAddedMessageComponent(), ); host.state.ui.requestRender(); } @@ -228,7 +228,6 @@ async function showGoalQueueManager( new GoalQueueManagerComponent({ goals: snapshot.goals, selectedGoalId, - colors: host.state.theme.colors, onAction: async (action) => { try { return await handleGoalQueueManagerAction(host, action); @@ -291,7 +290,6 @@ async function showGoalQueueEditDialog( host.mountEditorReplacement( new GoalQueueEditDialogComponent({ goal, - colors: host.state.theme.colors, onDone: (result) => { void handleGoalQueueEditResult(host, result).catch((error: unknown) => { host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`); @@ -354,7 +352,6 @@ function showGoalStartPermissionPrompt( }; host.mountEditorReplacement( new GoalStartPermissionPromptComponent({ - colors: host.state.theme.colors, mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', onSelect: (choice) => { if (choice === 'cancel') { @@ -416,7 +413,7 @@ async function startGoal( return false; } host.track('goal_create', { replace: parsed.replace }); - host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors)); + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); host.state.ui.requestRender(); if (options.sendInput !== undefined) { options.sendInput(parsed.objective); @@ -488,7 +485,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise { return; } host.state.transcriptContainer.addChild( - new GoalStatusMessageComponent(goal, host.state.theme.colors), + new GoalStatusMessageComponent(goal), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index f49b8d22f..73cc99824 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -87,8 +87,7 @@ interface ManagedUsageResult { export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); const managedUsage = await loadManagedUsageReport(host); - const lines = buildUsageReportLines({ - colors: host.state.theme.colors, + const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, @@ -96,8 +95,8 @@ export async function showUsage(host: SlashCommandHost): Promise { maxContextTokens: host.state.appState.maxContextTokens, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + }; + const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -108,8 +107,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { loadManagedUsageReport(host), ]); const appState = host.state.appState; - const lines = buildStatusReportLines({ - colors: host.state.theme.colors, + const reportArgs = { version: appState.version, model: appState.model, workDir: appState.workDir, @@ -126,8 +124,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise { statusError: runtimeStatus.error, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + }; + const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -141,12 +139,12 @@ export async function showMcpServers(host: SlashCommandHost): Promise { return; } - const lines = buildMcpStatusReportLines({ - colors: host.state.theme.colors, - servers, - }); const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildMcpStatusReportLines({ servers }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 0420e07a8..bbcd883a0 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -154,7 +154,6 @@ async function showPluginsPicker( plugins, selectedId: options?.selectedId, pluginHint: options?.pluginHint, - colors: host.state.theme.colors, onSelect: (selection) => { // 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 @@ -181,7 +180,6 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri entries: marketplace.plugins, installedIds: new Set(installed.map((plugin) => plugin.id)), source: marketplace.source, - colors: host.state.theme.colors, onSelect: (selection) => { // Every marketplace action re-mounts a picker, so let the handler do // the mounting — pre-restoring the editor here would flash. @@ -218,7 +216,6 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, - colors: host.state.theme.colors, onSelect: (selection) => { // Every MCP action re-mounts a picker, so let the handler do the // mounting — pre-restoring the editor here would flash on toggle. @@ -247,7 +244,6 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< new PluginRemoveConfirmComponent({ id, displayName, - colors: host.state.theme.colors, onDone: (result: PluginRemoveConfirmResult) => { host.restoreEditor(); resolveConfirmed(result.kind === 'confirm'); @@ -375,20 +371,23 @@ async function renderPluginsList( plugins?: readonly PluginSummary[], ): Promise { const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); - const lines = buildPluginsListLines({ - colors: host.state.theme.colors, - plugins: currentPlugins, - }); const title = ` Plugins (${currentPlugins.length}) `; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildPluginsListLines({ plugins: currentPlugins }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const info = await host.requireSession().getPluginInfo(id); - const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `); + const panel = new UsagePanelComponent( + () => buildPluginsInfoLines({ info }), + 'primary', + ` ${info.id} `, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index a6b7a8c80..67fd89a29 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -21,7 +21,6 @@ import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { return new Promise((resolve) => { const selector = new PlatformSelectorComponent({ - colors: host.state.theme.colors, onSelect: (platformId) => { host.restoreEditor(); resolve(platformId); @@ -45,7 +44,6 @@ export function promptLogoutProviderSelection( title: 'Select a provider to log out', options, currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value); @@ -64,7 +62,7 @@ export function promptFeedbackInput(host: SlashCommandHost): Promise { host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); - }, host.state.theme.colors); + }); host.mountEditorReplacement(dialog); }); } @@ -82,7 +80,6 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); @@ -109,7 +106,6 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: const picker = new ChoicePickerComponent({ title: 'Select a provider', options, - colors: host.state.theme.colors, searchable: true, onSelect: (value) => { host.restoreEditor(); @@ -172,7 +168,6 @@ export function runModelSelector( models: modelDict, currentValue: firstAlias, currentThinking: initialThinking, - colors: host.state.theme.colors, searchable: true, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index e8c4edfc1..55f9817fa 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -49,7 +49,6 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt return { providers: host.state.appState.availableProviders, activeProviderId, - colors: host.state.theme.colors, onAdd: () => { void handleProviderAdd(host); }, @@ -132,7 +131,6 @@ function promptProviderAddSource( { value: 'known', label: 'Known third-party provider' }, { value: 'custom', label: 'Custom registry (api.json)' }, ], - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value === 'known' || value === 'custom' ? value : undefined); @@ -232,7 +230,6 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -304,7 +301,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise count === 1 ? 'Imported 1 provider from registry.' : `Imported ${String(count)} providers from registry.`, - host.state.theme.colors.success, + 'success', ); // Offer the model selector so the user can pick a default, just like the @@ -321,7 +318,6 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise currentValue: host.state.appState.model, selectedValue: firstNewAlias, currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -344,7 +340,6 @@ function promptCustomRegistryImport( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a93d7b6ec..a8700d95e 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,13 +1,14 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { const tuiConfig = await loadTuiConfig(); - applyReloadedTuiConfig(host, tuiConfig); - host.showStatus('TUI config reloaded.', host.state.theme.colors.success); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise { @@ -23,22 +24,24 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); - applyReloadedTuiConfig(host, tuiConfig); + await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { host.showStatus( 'Runtime and TUI config reloaded; no active session.', - host.state.theme.colors.success, + 'success', ); } } -export function applyReloadedTuiConfig( +export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, -): void { - const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme; - host.applyTheme(config.theme, resolved); +): Promise { + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 65baa2fcc..01e3ae012 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -57,7 +57,6 @@ function showSwarmStartPermissionPrompt( }; host.mountEditorReplacement( new SwarmStartPermissionPromptComponent({ - colors: host.state.theme.colors, onSelect: (choice) => { host.restoreEditor(); void onSelect(choice); @@ -149,7 +148,7 @@ function swarmModeSubcommand(input: string): boolean | undefined { function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 286d5ec8a..c3228b139 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -186,6 +186,6 @@ function renderWelcome(host: SlashCommandHost): void { return; } host.state.transcriptContainer.addChild( - new WelcomeComponent(host.state.appState, host.state.theme.colors), + new WelcomeComponent(host.state.appState), ); } diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index de2704228..ad9044d7d 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -8,16 +8,14 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface DeviceCodeBoxParams { readonly title: string; readonly url: string; readonly code: string; readonly hint?: string; - readonly colors: ColorPalette; } export class DeviceCodeBoxComponent implements Component { @@ -30,28 +28,28 @@ export class DeviceCodeBoxComponent implements Component { invalidate(): void {} render(width: number): string[] { - const { title, url, code, hint, colors } = this.params; - const border = (s: string): string => chalk.hex(colors.primary)(s); + const { title, url, code, hint } = this.params; + const border = (s: string): string => currentTheme.fg('primary', s); const safeWidth = Math.max(28, width); const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…'); + const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…'); const promptLine = truncateToWidth( - chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'), + currentTheme.fg('textDim', 'Visit the URL below in your browser to authorize:'), innerWidth, '…', ); - const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…'); + const urlLine = truncateToWidth(currentTheme.fg('primary', url), innerWidth, '…'); - const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: '); - const codeValue = chalk.bold.hex(colors.accent)(code); + const codeLabel = currentTheme.boldFg('textDim', 'Verification code: '); + const codeValue = currentTheme.boldFg('accent', code); const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; if (hint !== undefined && hint.length > 0) { contentLines.push(''); - contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…')); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); } const lines: string[] = [ diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a81751f6c..127009506 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -11,6 +11,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { @@ -206,7 +207,7 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { - const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); + const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; const pullRequest = chalk.hex(colors.primary)( @@ -217,7 +218,6 @@ export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): s export class FooterComponent implements Component { private state: AppState; - private colors: ColorPalette; private readonly onRefresh: () => void; private gitCache: GitStatusCache; private gitCacheWorkDir: string; @@ -235,9 +235,8 @@ export class FooterComponent implements Component { private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; - constructor(state: AppState, colors: ColorPalette, onRefresh: () => void = () => {}) { + constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; - this.colors = colors; this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); @@ -255,10 +254,6 @@ export class FooterComponent implements Component { this.state = state; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -282,7 +277,7 @@ export class FooterComponent implements Component { invalidate(): void {} render(width: number): string[] { - const colors = this.colors; + const colors = currentTheme.palette; const state = this.state; // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── @@ -303,7 +298,7 @@ export class FooterComponent implements Component { const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { - renderedModelLabel = renderDanceFooterModel(modelLabel, colors); + renderedModelLabel = renderDanceFooterModel(modelLabel); } left.push(renderedModelLabel); } @@ -325,7 +320,7 @@ export class FooterComponent implements Component { } const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.status)(cwd)); + if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); const git = this.gitCache.getStatus(); if (git !== null) { diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 45ed12c83..9e02e2fbf 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -13,6 +13,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; export type TodoStatus = 'pending' | 'in_progress' | 'done'; @@ -98,11 +99,6 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; - private colors: ColorPalette; - - constructor(colors: ColorPalette) { - this.colors = colors; - } setTodos(todos: readonly TodoItem[]): void { this.todos = todos.map((t) => ({ title: t.title, status: t.status })); @@ -120,15 +116,11 @@ export class TodoPanelComponent implements Component { return this.todos.length === 0; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return []; - const c = this.colors; + const c = currentTheme.palette; const { rows, hidden } = selectVisibleTodos(this.todos); const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 0f8a0b05e..65d17d860 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -8,22 +8,20 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { currentTheme } from '#/tui/theme'; export class WelcomeComponent implements Component { private state: AppState; - private colors: ColorPalette; - constructor(state: AppState, colors: ColorPalette) { + constructor(state: AppState) { this.state = state; - this.colors = colors; } invalidate(): void {} render(width: number): string[] { - const primary = (s: string): string => chalk.hex(this.colors.primary)(s); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); const innerWidth = Math.max(10, width - 4); const pad = ' '; @@ -34,13 +32,13 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(this.colors.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), textWidth, '…', ); const isLoggedOut = !this.state.model; - const dim = chalk.hex(this.colors.textDim); - const labelStyle = chalk.bold.hex(this.colors.textDim); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); const rightRow1 = truncateToWidth( dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), textWidth, @@ -52,12 +50,12 @@ export class WelcomeComponent implements Component { primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, ]; if (isRainbowDancing()) { - renderedHeaderLines = renderDanceWelcomeHeader(this.colors, logo, textWidth, rightRow1); + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); } const activeModel = this.state.availableModels[this.state.model]; const modelValue = isLoggedOut - ? chalk.hex(this.colors.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); const infoLines = [ diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 2a06a4278..a8c0ceb14 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -7,9 +7,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } @@ -47,7 +46,6 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly input = new Input(); private readonly onDone: (result: ApiKeyInputResult) => void; - private readonly colors: ColorPalette; private readonly title: string; private readonly subtitleLines: readonly string[]; private done = false; @@ -57,11 +55,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - colors: ColorPalette, ) { super(); this.onDone = onDone; - this.colors = colors; this.title = `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; this.input.onSubmit = (value) => { @@ -97,13 +93,13 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', this.title); const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => - truncateToWidth(chalk.hex(this.colors.textDim)(line), innerWidth, '…'), + truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index ad92e0326..1f1a55403 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -15,8 +15,7 @@ import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { @@ -26,7 +25,6 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; @@ -50,13 +48,13 @@ interface BlockStyles { errorBold: (s: string) => string; } -function makeBlockStyles(colors: ColorPalette): BlockStyles { +function makeBlockStyles(): BlockStyles { return { - strong: (s) => chalk.hex(colors.textStrong)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - accent: (s) => chalk.hex(colors.accent)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - errorBold: (s) => chalk.bold.hex(colors.error)(s), + strong: (s) => currentTheme.fg('textStrong', s), + dim: (s) => currentTheme.fg('textDim', s), + accent: (s) => currentTheme.fg('accent', s), + gutter: (s) => currentTheme.fg('diffGutter', s), + errorBold: (s) => currentTheme.boldFg('error', s), }; } @@ -105,12 +103,11 @@ function renderShellDisplayBlock( function renderDisplayBlock( block: DisplayBlock, s: BlockStyles, - colors: ColorPalette, contentWidth: number, ): string[] { switch (block.type) { case 'diff': - return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, { + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, { contextLines: 3, expandKeyHint: 'ctrl+e to preview', maxLines: DIFF_SUMMARY_MAX_LINES, @@ -215,7 +212,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { private readonly feedbackInput = new Input(); private onResponse: (response: ApprovalPanelResponse) => void; private request: PendingApproval; - private readonly colors: ColorPalette; private readonly onToggleToolOutput: (() => void) | undefined; private readonly onOpenPreview: | ((block: DiffDisplayBlock | FileContentDisplayBlock) => void) @@ -224,14 +220,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { constructor( request: PendingApproval, onResponse: (response: ApprovalPanelResponse) => void, - colors: ColorPalette, onToggleToolOutput?: () => void, onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, ) { super(); this.request = request; this.onResponse = onResponse; - this.colors = colors; this.onToggleToolOutput = onToggleToolOutput; this.onOpenPreview = onOpenPreview; this.feedbackInput.onSubmit = (value) => { @@ -328,12 +322,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { this.ensureValidSelection(); this.feedbackInput.focused = this.focused && this.feedbackMode; const { data } = this.request; - const blockStyles = makeBlockStyles(this.colors); - const borderColor = chalk.hex(this.colors.borderFocus); - const borderColorBold = chalk.bold.hex(this.colors.borderFocus); - const selectColorBold = chalk.bold.hex(this.colors.accent); - const dim = chalk.hex(this.colors.textDim); - const strong = chalk.hex(this.colors.textStrong); + const blockStyles = makeBlockStyles(); + const borderColor = (text: string) => currentTheme.fg('borderFocus', text); + const borderColorBold = (text: string) => currentTheme.boldFg('borderFocus', text); + const selectColorBold = (text: string) => currentTheme.boldFg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const strong = (text: string) => currentTheme.fg('textStrong', text); const horizontalBar = borderColor('─'.repeat(width)); const indent = (s: string): string => ` ${s}`; @@ -357,7 +351,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { const blockLines = renderDisplayBlock( block, blockStyles, - this.colors, Math.max(1, width - 2), ); for (const line of blockLines) { @@ -433,8 +426,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { } private renderInlineFeedbackLine(width: number, labelWithNum: string): string { - const selectColorBold = chalk.bold.hex(this.colors.accent); - const prefix = `${selectColorBold('▶')} ${selectColorBold(labelWithNum)} `; + const prefix = `${currentTheme.boldFg('accent', '▶')} ${currentTheme.boldFg('accent', labelWithNum)} `; const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 7853f7e23..15974959f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -25,12 +25,11 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLines } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -39,7 +38,6 @@ export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock; export interface ApprovalPreviewViewerProps { readonly block: ApprovalPreviewBlock; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -62,9 +60,9 @@ export class ApprovalPreviewViewer extends Container implements Focusable { private readonly props: ApprovalPreviewViewerProps; private readonly terminal: Terminal; /** Pre-rendered body lines (ANSI-styled, no border / no gutter). */ - private readonly bodyLines: string[]; + private bodyLines: string[]; /** Title shown in the header (path + diff stats / "Write" label). */ - private readonly headerTitle: string; + private headerTitle: string; /** Index of the topmost visible line. */ private scrollTop = 0; @@ -72,7 +70,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { super(); this.props = props; this.terminal = terminal; - const built = buildBody(props.block, props.colors); + const built = buildBody(props.block); this.bodyLines = built.lines; this.headerTitle = built.title; } @@ -98,11 +96,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '') { + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '') { + if (matchesKey(data, Key.pageDown) || data === '\x06') { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -120,9 +118,15 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollTo(this.scrollTop + delta); } + override invalidate(): void { + const built = buildBody(this.props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + private scrollTo(target: number): void { this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); - this.invalidate(); + super.invalidate(); } private maxScroll(): number { @@ -146,14 +150,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Preview '); + const title = currentTheme.boldFg('primary', ' Preview '); return fitExactly(title + this.headerTitle, width); } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; const innerWidth = Math.max(1, width - 4); const max = this.maxScroll(); @@ -161,23 +162,22 @@ export class ApprovalPreviewViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.bodyLines[lineIndex] ?? ''; - out.push(chalk.hex(stroke)('│ ') + fitExactly(raw, innerWidth) + chalk.hex(stroke)(' │')); + out.push(currentTheme.fg('primary', '│ ') + fitExactly(raw, innerWidth) + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.bodyLines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -186,7 +186,8 @@ export class ApprovalPreviewViewer extends Container implements Focusable { const lineFrom = total === 0 ? 0 : this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = @@ -209,14 +210,14 @@ interface BuiltBody { title: string; } -function buildBody(block: ApprovalPreviewBlock, colors: ColorPalette): BuiltBody { +function buildBody(block: ApprovalPreviewBlock): BuiltBody { if (block.type === 'diff') { - return buildDiffBody(block, colors); + return buildDiffBody(block); } - return buildFileContentBody(block, colors); + return buildFileContentBody(block); } -function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody { +function buildDiffBody(block: DiffDisplayBlock): BuiltBody { // renderDiffLines emits a `+N -M path` header on its first line followed // by every changed line. We pull the header out into the viewer chrome so // the body is purely scrollable diff content; this also means we don't @@ -225,7 +226,6 @@ function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody block.old_text, block.new_text, block.path, - colors, false, block.old_start ?? 1, block.new_start ?? 1, @@ -234,14 +234,13 @@ function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody return { lines: rest, title: stripLeadingSpace(header) }; } -function buildFileContentBody(block: FileContentDisplayBlock, colors: ColorPalette): BuiltBody { +function buildFileContentBody(block: FileContentDisplayBlock): BuiltBody { const lang = block.language ?? langFromPath(block.path); const highlighted = highlightLines(block.content, lang); - const gutter = chalk.hex(colors.diffGutter); const lines = highlighted.map( - (line, i) => gutter(String(i + 1).padStart(4) + ' ') + line, + (line, i) => currentTheme.fg('diffGutter', String(i + 1).padStart(4) + ' ') + line, ); - const title = chalk.hex(colors.textStrong)(block.path); + const title = currentTheme.fg('textStrong', block.path); return { lines, title }; } diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index d5375d776..c03444f9b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -16,10 +16,8 @@ import { visibleWidth, type Focusable, } 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 { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -37,11 +35,10 @@ export interface ChoiceOption { export interface ChoicePickerOptions { readonly title: string; readonly hint?: string; - readonly formatHint?: (text: string, colors: ColorPalette) => string; + readonly formatHint?: (text: string) => string; readonly notice?: string; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate. */ @@ -118,7 +115,6 @@ export class ChoicePickerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const options = view.items; @@ -132,41 +128,41 @@ export class ChoicePickerComponent extends Container implements Focusable { const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = - searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix, + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, this.opts.formatHint === undefined - ? chalk.hex(colors.textMuted)(` ${hint}`) - : this.opts.formatHint(` ${hint}`, colors), + ? currentTheme.fg('textMuted', ` ${hint}`) + : this.opts.formatHint(` ${hint}`), ]; if (this.opts.notice !== undefined) { - lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`)); + lines.push(currentTheme.fg('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)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (options.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { const opt = options[i]!; const isSelected = i === view.selectedIndex; const isCurrent = opt.value === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; - const labelStyle = optionLabelStyle(opt, isSelected, colors); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = optionLabelStyle(opt, isSelected); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); line += labelStyle(opt.label); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${descLine}`)); + lines.push(currentTheme.fg('textMuted', ` ${descLine}`)); } } } @@ -174,12 +170,12 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(''); if (view.page.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg('textMuted', ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, ), ); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } } @@ -187,10 +183,13 @@ export class ChoicePickerComponent extends Container implements Focusable { function optionLabelStyle( option: ChoiceOption, selected: boolean, - colors: ColorPalette, ): (text: string) => string { if (option.tone === 'danger') { - return selected ? chalk.hex(colors.error).bold : chalk.hex(colors.error); + return selected + ? (text) => currentTheme.boldFg('error', text) + : (text) => currentTheme.fg('error', text); } - return selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + return selected + ? (text) => currentTheme.boldFg('primary', text) + : (text) => currentTheme.fg('text', text); } diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 6a55ede98..f2ef1a75c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -15,17 +15,16 @@ import { Container, Text, Spacer } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { - private readonly colors: ColorPalette; private readonly ui: TUI | undefined; private readonly headerText: Text; + private readonly instruction: string | undefined; private blinkOn = true; private blinkTimer: ReturnType | null = null; private done = false; @@ -33,23 +32,42 @@ export class CompactionComponent extends Container { private tokensBefore: number | undefined; private tokensAfter: number | undefined; - constructor(colors: ColorPalette, ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined) { super(); - this.colors = colors; this.ui = ui; + this.instruction = instruction; // Top margin so the block isn't glued to the previous transcript // entry (status line, tool result, etc.). this.addChild(new Spacer(1)); this.headerText = new Text(this.buildHeader(), 0, 0); this.addChild(this.headerText); - if (instruction !== undefined) { - this.addChild(new Text(chalk.dim(` ${instruction}`), 0, 0)); - } + this.addInstructionChild(); this.startBlink(); } + private addInstructionChild(): void { + if (this.instruction !== undefined) { + this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0)); + } + } + + override invalidate(): void { + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // Rebuild instruction line with fresh theme colours. + if (this.instruction !== undefined) { + // Remove the last child if it is the instruction line (it is always + // added after headerText and Spacer). + if (this.children.length > 2) { + this.children.pop(); + } + this.addInstructionChild(); + } + super.invalidate(); + } + markDone(tokensBefore?: number, tokensAfter?: number): void { if (this.done || this.canceled) return; this.done = true; @@ -74,21 +92,21 @@ export class CompactionComponent extends Container { private buildHeader(): string { if (this.done) { - const bullet = chalk.hex(this.colors.success)(STATUS_BULLET); - const label = chalk.hex(this.colors.success).bold('Compaction complete'); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); const detail = this.tokensBefore !== undefined && this.tokensAfter !== undefined - ? chalk.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; return `${bullet}${label}${detail}`; } if (this.canceled) { - const bullet = chalk.hex(this.colors.warning)(STATUS_BULLET); - const label = chalk.hex(this.colors.warning).bold('Compaction cancelled'); + const bullet = currentTheme.fg('warning', STATUS_BULLET); + const label = currentTheme.boldFg('warning', 'Compaction cancelled'); return `${bullet}${label}`; } - const bullet = this.blinkOn ? chalk.hex(this.colors.roleAssistant)(STATUS_BULLET) : ' '; - const label = chalk.hex(this.colors.primary).bold('Compacting context...'); + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context...'); return `${bullet}${label}`; } diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index ec2f1d389..d85b80170 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -18,9 +18,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface CustomRegistryImportValue { readonly url: string; @@ -54,7 +53,7 @@ function maskInputLine(raw: string): string { // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) // while masking every other visible character. - const parts = content.split(/((?:\[[0-9;]*m|_pi:c))/); + const parts = content.split(/(\x1B(?:\[[0-9;]*m|_pi:c\x07))/); const maskedContent = parts .map((part, index) => { if (index % 2 === 1) return part; // ANSI sequence @@ -71,19 +70,16 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo private readonly urlInput = new Input(); private readonly tokenInput = new Input(); private readonly onDone: (result: CustomRegistryImportResult) => void; - private readonly colors: ColorPalette; private activeField: FieldId = 'url'; private done = false; private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; constructor( onDone: (result: CustomRegistryImportResult) => void, - colors: ColorPalette, defaultUrl: string = '', ) { super(); 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. @@ -145,16 +141,17 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.hint === 'url-empty' ? SUBTITLE_URL_EMPTY : this.hint === 'token-empty' ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)( + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg( + 'textDim', this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, ); @@ -162,12 +159,12 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo const tokenLabelText = 'Bearer token'; const urlLabelStyled = this.activeField === 'url' - ? chalk.bold.hex(this.colors.accent)(urlLabelText) - : chalk.hex(this.colors.textDim)(urlLabelText); + ? currentTheme.boldFg('accent', urlLabelText) + : currentTheme.fg('textDim', urlLabelText); const tokenLabelStyled = this.activeField === 'token' - ? chalk.bold.hex(this.colors.accent)(tokenLabelText) - : chalk.hex(this.colors.textDim)(tokenLabelText); + ? currentTheme.boldFg('accent', tokenLabelText) + : currentTheme.fg('textDim', tokenLabelText); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts index 24467dd05..9e98a457b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const EDITOR_OPTIONS: readonly ChoiceOption[] = [ { value: 'code --wait', label: 'VS Code (code --wait)' }, { value: 'vim', label: 'Vim' }, @@ -12,7 +10,6 @@ const EDITOR_OPTIONS: readonly ChoiceOption[] = [ export interface EditorSelectorOptions { readonly currentValue: string; - readonly colors: ColorPalette; readonly onSelect: (value: string) => void; readonly onCancel: () => void; } @@ -23,7 +20,6 @@ export class EditorSelectorComponent extends ChoicePickerComponent { title: 'Select external editor', options: [...EDITOR_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index c7dcb8da6..44042f057 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -7,10 +7,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { ExperimentalFeatureState } 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 { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -23,7 +22,6 @@ export interface ExperimentalFeatureDraftChange { export interface ExperimentsSelectorOptions { readonly features: readonly ExperimentalFeatureState[]; - readonly colors: ColorPalette; readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; readonly onCancel: () => void; } @@ -66,28 +64,27 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const titleSuffix = - view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const hintParts = ['↑↓ navigate']; if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); if (view.query.length > 0) hintParts.push('Backspace clear'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Experimental features') + titleSuffix, - chalk.hex(colors.textMuted)(` ${hintParts.join(' · ')}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Experimental features') + titleSuffix, + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), '', ]; if (view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { @@ -99,19 +96,21 @@ export class ExperimentsSelectorComponent extends Container implements Focusable lines.push(''); if (view.query.length > 0) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, ), ); } else if (view.page.end < view.items.length) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ▼ ${String(view.items.length - view.page.end)} more`, ), ); } lines.push(this.renderApplyButton()); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -145,15 +144,18 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } private renderApplyButton(): string { - const { colors } = this.opts; const changes = this.draftChanges(); const count = changes.length; const label = '[ Apply changes and reload ]'; const summary = count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; - const buttonStyle = count === 0 ? chalk.hex(colors.textDim) : chalk.hex(colors.primary).bold; - const summaryStyle = count === 0 ? chalk.hex(colors.textMuted) : chalk.hex(colors.success); - return ` ${buttonStyle(label)} ${summaryStyle(summary)}`; + const button = count === 0 + ? currentTheme.fg('textDim', label) + : currentTheme.boldFg('primary', label); + const summaryText = count === 0 + ? currentTheme.fg('textMuted', summary) + : currentTheme.fg('success', summary); + return ` ${button} ${summaryText}`; } private renderFeature( @@ -161,23 +163,22 @@ export class ExperimentsSelectorComponent extends Container implements Focusable selected: boolean, width: number, ): string[] { - const { colors } = this.opts; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const label = selected ? currentTheme.boldFg('primary', feature.title) : currentTheme.fg('text', feature.title); const enabled = this.effectiveEnabled(feature); const status = enabled ? 'enabled' : 'disabled'; - const statusStyle = enabled ? chalk.hex(colors.success) : chalk.hex(colors.textDim); + const statusText = enabled ? currentTheme.fg('success', status) : currentTheme.fg('textDim', status); const detail = this.isDraftChanged(feature) ? `${featureDetail(feature)} · modified` : featureDetail(feature); const lines = [ - `${prefix}${labelStyle(feature.title)} ${statusStyle(status)}`, - chalk.hex(colors.textMuted)(` ${detail}`), + `${prefix}${label} ${statusText}`, + currentTheme.fg('textMuted', ` ${detail}`), ]; const descriptionWidth = Math.max(1, width - 4); for (const line of wrapText(feature.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${line}`)); + lines.push(currentTheme.fg('textMuted', ` ${line}`)); } return lines; } diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 1fb963625..8680b4a03 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -16,9 +16,7 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } @@ -34,14 +32,12 @@ export class FeedbackInputDialogComponent extends Container implements Focusable private readonly input = new Input(); private readonly onDone: (result: FeedbackInputDialogResult) => void; - private readonly colors: ColorPalette; private done = false; private emptyHinted = false; - constructor(onDone: (result: FeedbackInputDialogResult) => void, colors: ColorPalette) { + constructor(onDone: (result: FeedbackInputDialogResult) => void) { super(); this.onDone = onDone; - this.colors = colors; this.input.onSubmit = (value) => { this.submit(value); }; @@ -75,11 +71,11 @@ export class FeedbackInputDialogComponent extends Container implements Focusable const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index bf5f72356..e2df47676 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -15,7 +15,7 @@ import type { GoalQueueSnapshot, UpcomingGoal, } from '#/tui/goal-queue-store'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -42,7 +42,6 @@ export type GoalQueueManagerAction = export interface GoalQueueManagerOptions { readonly goals: readonly UpcomingGoal[]; readonly selectedGoalId?: string; - readonly colors: ColorPalette; readonly pageSize?: number; readonly onAction: ( action: GoalQueueManagerAction, @@ -56,7 +55,6 @@ export type GoalQueueEditResult = export interface GoalQueueEditDialogOptions { readonly goal: UpcomingGoal; - readonly colors: ColorPalette; readonly onDone: (result: GoalQueueEditResult) => void; } @@ -115,20 +113,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const hint = this.movingGoalId === undefined ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Upcoming goals'), - chalk.hex(colors.textMuted)(` ${hint}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Upcoming goals'), + currentTheme.fg('textMuted', ` ${hint}`), '', ]; if (this.goals.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No upcoming goals.')); + lines.push(currentTheme.fg('textMuted', ' No upcoming goals.')); } else { for (let i = view.page.start; i < view.page.end; i++) { const goal = view.items[i]; @@ -139,20 +136,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { const below = view.items.length - view.page.end; if (below > 0) { lines.push(''); - lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { - const { colors } = this.opts; const moving = goal.id === this.movingGoalId; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); const labelPrefix = `${String(index + 1)}. `; const stateLabel = moving ? ' selected' : ''; const labelWidth = visibleWidth(labelPrefix); @@ -163,9 +159,11 @@ export class GoalQueueManagerComponent extends Container implements Focusable { objectiveWidth, ELLIPSIS, ); - const textStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const textStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); let line = prefix + textStyle(labelPrefix + objective); - if (moving) line += chalk.hex(colors.success)(stateLabel); + if (moving) line += currentTheme.fg('success', stateLabel); return line; } @@ -246,15 +244,15 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable const safeWidth = Math.max(28, width); const innerWidth = Math.max(10, safeWidth - 4); const pad = ' '; - const { colors } = this.opts; - const border = (s: string): string => chalk.hex(colors.primary)(s); + const border = (s: string): string => currentTheme.fg('primary', s); const title = truncateToWidth( - chalk.hex(colors.textStrong).bold('Edit upcoming goal'), + currentTheme.boldFg('textStrong', 'Edit upcoming goal'), innerWidth, ELLIPSIS, ); const subtitle = truncateToWidth( - chalk.hex(this.error === undefined ? colors.textDim : colors.warning)( + currentTheme.fg( + this.error === undefined ? 'textDim' : 'warning', this.error ?? 'Update the queued objective.', ), innerWidth, @@ -262,7 +260,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable ); const inputLines = this.input.render(innerWidth); const footer = truncateToWidth( - chalk.hex(colors.textDim)('Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), + currentTheme.fg('textDim', 'Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), innerWidth, ELLIPSIS, ); diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index ade2ba69e..c7cc39923 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -1,5 +1,3 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, @@ -8,7 +6,6 @@ import { export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; export interface GoalStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; @@ -75,7 +72,6 @@ const YOLO_NOTICE_LINES = [ export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: GoalStartPermissionPromptOptions) { super({ - colors: opts.colors, title: opts.mode === 'yolo' ? 'Start a goal in YOLO mode?' diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 9b219a0c4..1bbb743a0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -16,9 +16,7 @@ import { type Focusable, truncateToWidth, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { readonly keys: string; @@ -48,7 +46,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ export interface HelpPanelOptions { readonly commands: readonly HelpPanelCommand[]; readonly shortcuts?: readonly KeyboardShortcut[]; - readonly colors: ColorPalette; readonly onClose: () => void; /** Terminal height — used to decide whether to show the hint tail. */ readonly maxVisible?: number; @@ -93,12 +90,11 @@ export class HelpPanelComponent extends Container implements Focusable { } override render(width: number): string[] { - const colors = this.opts.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const muted = chalk.hex(colors.textMuted); - const kbdColor = chalk.hex(colors.warning); - const slashColor = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const muted = (text: string) => currentTheme.fg('textMuted', text); + const kbdColor = (text: string) => currentTheme.fg('warning', text); + const slashColor = (text: string) => currentTheme.fg('primary', text); const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); @@ -110,17 +106,17 @@ 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 cancel · ↑↓ scroll'), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, '', // Section: keyboard shortcuts - ` ${chalk.bold('Keyboard shortcuts')}`, + ` ${currentTheme.bold('Keyboard shortcuts')}`, ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), '', // Section: slash commands - ` ${chalk.bold('Slash commands')}`, + ` ${currentTheme.bold('Slash commands')}`, ...sortedCmds.map((cmd, i) => { const label = cmdLabels[i] ?? `/${cmd.name}`; return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index e9ba8c64d..b6a9ec6ea 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -7,11 +7,10 @@ import { 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 { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -58,7 +57,6 @@ export interface ModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -166,14 +164,13 @@ export class ModelSelectorComponent extends Container implements Focusable { } 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 = searchable && view.query.length === 0 - ? chalk.hex(colors.textMuted)(' (type to search)') + ? currentTheme.fg('textMuted', ' (type to search)') : ''; // "type to search" already lives in the title suffix, so the hint only @@ -185,18 +182,18 @@ export class ModelSelectorComponent extends Container implements Focusable { hintParts.push('Enter select', 'Esc cancel'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Select a model') + titleSuffix, - chalk.hex(colors.textMuted)(' ' + hintParts.join(' · ')), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), '', ]; if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('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. @@ -214,14 +211,13 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === view.selectedIndex; const isCurrent = choice.alias === this.opts.currentValue; 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 += nameStyle(truncatedName) + namePad; - line += ' ' + chalk.hex(colors.textMuted)(choice.provider); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += (isSelected ? currentTheme.boldFg('primary', truncatedName) : currentTheme.fg('text', truncatedName)) + namePad; + line += ' ' + currentTheme.fg('textMuted', choice.provider); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); } @@ -231,13 +227,13 @@ export class ModelSelectorComponent extends Container implements Focusable { if (view.query.length > 0) { lines.push(''); lines.push( - chalk.hex(colors.textMuted)(` ${String(view.items.length)} / ${String(totalCount)}`), + currentTheme.fg('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(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } @@ -246,11 +242,11 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected !== undefined) { const availability = thinkingAvailability(selected.model); const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; - lines.push(chalk.hex(colors.textMuted)(thinkingHeader)); + lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } @@ -259,18 +255,17 @@ export class ModelSelectorComponent extends Container implements Focusable { } 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} `); + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); 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('Off', true)} ${currentTheme.fg('textMuted', 'unsupported')}`; } const draft = this.draftFor(choice); return ` ${segment('On', draft)} ${segment('Off', !draft)}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 6445206d3..91668b4f0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -2,8 +2,6 @@ import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', @@ -31,7 +29,6 @@ function isPermissionModeChoice(value: string): value is PermissionMode { export interface PermissionSelectorOptions { readonly currentValue: PermissionMode; - readonly colors: ColorPalette; readonly onSelect: (mode: PermissionMode) => void; readonly onCancel: () => void; } @@ -42,7 +39,6 @@ export class PermissionSelectorComponent extends ChoicePickerComponent { title: 'Select permission mode', options: [...PERMISSION_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { if (isPermissionModeChoice(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index c1d8a1467..a332f70af 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -2,15 +2,12 @@ import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), ]; export interface PlatformSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (platformId: string) => void; readonly onCancel: () => void; } @@ -20,7 +17,6 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { super({ title: 'Select a platform', options: [...PLATFORM_OPTIONS], - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 49091009e..daf1155a9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -7,10 +7,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; 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 { currentTheme } from '#/tui/theme'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; @@ -51,7 +50,6 @@ export interface PluginsOverviewSelectorOptions { readonly id: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginsOverviewSelection) => void; readonly onCancel: () => void; } @@ -121,21 +119,21 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } override render(width: number): string[] { - const { colors, plugins } = this.opts; + const { plugins } = this.opts; const hint = '↑↓ 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'), - mutedHintLine(` ${hint}`, colors), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Plugins'), + mutedHintLine(` ${hint}`), '', - sectionLabel(`Installed plugins (${plugins.length})`, colors), + sectionLabel(`Installed plugins (${plugins.length})`), ]; if (pluginItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + lines.push(currentTheme.fg('textMuted', ' No plugins installed.')); } else { let absoluteIndex = 0; for (const item of pluginItems) { @@ -145,35 +143,36 @@ export class PluginsOverviewSelectorComponent extends Container implements Focus } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; 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} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const pluginId = overviewItemPluginId(item); if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); + line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -187,7 +186,6 @@ export interface PluginMarketplaceSelectorOptions { readonly entries: readonly PluginMarketplaceEntry[]; readonly installedIds: ReadonlySet; readonly source: string; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMarketplaceSelection) => void; readonly onCancel: () => void; } @@ -232,20 +230,19 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc } override render(width: number): string[] { - const { colors } = this.opts; const entries = this.items.filter((item) => item.kind === 'plugin'); const actions = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Official plugins'), - mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel', colors), - chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Official plugins'), + mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel'), + currentTheme.fg('textMuted', ` Source: ${this.opts.source}`), '', - sectionLabel(`Marketplace (${entries.length})`, colors), + sectionLabel(`Marketplace (${entries.length})`), ]; if (entries.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No marketplace plugins found.')); + lines.push(currentTheme.fg('textMuted', ' No marketplace plugins found.')); } else { for (let i = 0; i < entries.length; i++) { lines.push(...this.renderItem(entries[i]!, i, width)); @@ -253,30 +250,31 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actions.length; i++) { lines.push(...this.renderItem(actions[i]!, entries.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; 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} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -293,7 +291,6 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -349,19 +346,19 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors, info } = this.opts; + const { info } = this.opts; const serverItems = 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(` MCP servers · ${info.displayName}`), - mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'), '', - sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), + sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`), ]; if (serverItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.')); + lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.')); } else { for (let i = 0; i < serverItems.length; i++) { lines.push(...this.renderItem(serverItems[i]!, i, width)); @@ -369,34 +366,35 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } lines.push(''); - lines.push(sectionLabel('Actions', colors)); + lines.push(sectionLabel('Actions')); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; const selected = index === this.selectedIndex; 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} `); + const labelStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); + line += ' ' + statusStyle(item)(item.status); } const serverName = mcpItemServerName(item); if (serverName !== undefined && this.opts.serverHint?.server === serverName) { - line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text); + line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); + lines.push(mutedHintLine(` ${descLine}`)); } return lines; } @@ -409,7 +407,6 @@ export type PluginRemoveConfirmResult = export interface PluginRemoveConfirmOptions { readonly id: string; readonly displayName: string; - readonly colors: ColorPalette; readonly onDone: (result: PluginRemoveConfirmResult) => void; } @@ -432,7 +429,6 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { description: 'Remove only the install record; plugin files are left in place.', }, ], - colors: opts.colors, onSelect: (value) => { opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' }); }, @@ -579,24 +575,23 @@ function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } -function sectionLabel(label: string, colors: ColorPalette): string { - return chalk.hex(colors.textDim).bold(` ${label}`); +function sectionLabel(label: string): string { + return currentTheme.boldFg('textDim', ` ${label}`); } function statusStyle( item: PluginsOverviewItem, - colors: ColorPalette, ): (text: string) => string { - if (item.kind === 'action') return chalk.hex(colors.textDim); - if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success); - if (item.status?.startsWith('install')) return chalk.hex(colors.primary); - if (item.status === 'disabled') return chalk.hex(colors.textDim); - if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim); - return chalk.hex(colors.warning); + if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); + if (item.status === 'enabled' || item.status === 'installed') return (text) => currentTheme.fg('success', text); + if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); + if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); + if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); + return (text) => currentTheme.fg('warning', text); } -function mutedHintLine(text: string, colors: ColorPalette): string { - return chalk.hex(colors.textMuted)(text); +function mutedHintLine(text: string): string { + return currentTheme.fg('textMuted', text); } function wrapOverviewDescription(text: string, width: number): string[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 2cc7fcccf..8805c24a9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -43,11 +43,10 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -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 { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -61,7 +60,6 @@ export interface ProviderManagerOptions { readonly providers: Record; /** Provider id of the currently active model. */ readonly activeProviderId?: string; - readonly colors: ColorPalette; readonly onAdd: () => void; /** Delete all providers under a source (Open Platform / custom-registry * fetch / standalone). Passed the full provider-id list so the host @@ -355,27 +353,26 @@ export class ProviderManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - 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)); + const border = currentTheme.fg('primary', '─'.repeat(width)); lines.push(border); - lines.push(chalk.hex(colors.primary).bold(' Providers')); - lines.push(chalk.hex(colors.textMuted)(' ' + HEADER_HINT)); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); lines.push(''); const rows = this.rows; if (rows.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No providers configured.')); + lines.push(currentTheme.fg('textMuted', ' No providers configured.')); } else { const view = this.page(); for (let i = view.start; i < view.end; i++) { const row = rows[i]; if (row === undefined) continue; - for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) { + for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width })) { lines.push(line); } } @@ -389,7 +386,8 @@ export class ProviderManagerComponent extends Container implements Focusable { const view = this.page(); if (view.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` Page ${String(view.page + 1)}/${String(view.pageCount)}`, ), ); @@ -401,10 +399,9 @@ export class ProviderManagerComponent extends Container implements Focusable { } private renderConfirmLine(width: number): string { - const { colors } = this.opts; const confirm = this.confirm; const prompt = confirm?.label ?? ''; - const styled = chalk.hex(colors.warning).bold(` ${prompt} [y/N]`); + const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); return truncateToWidth(styled, width, '…'); } } @@ -412,19 +409,21 @@ export class ProviderManagerComponent extends Container implements Focusable { function renderRow( row: Row, - ctx: { isSelected: boolean; width: number; colors: ColorPalette }, + ctx: { isSelected: boolean; width: number }, ): string[] { - const { isSelected, width, colors } = ctx; + const { isSelected, width } = ctx; const pointer = isSelected ? SELECT_POINTER : ' '; - const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim); + const pointerStyle = (text: string) => + isSelected ? currentTheme.fg('primary', text) : currentTheme.fg('textDim', 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 labelStyle = (text: string) => + isSelected + ? currentTheme.boldFg('primary', text) + : row.kind === 'add' + ? currentTheme.fg('primary', text) + : currentTheme.fg('text', text); // 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. @@ -435,13 +434,13 @@ function renderRow( const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); const labelText = truncateToWidth(row.label, labelWidth, '…'); let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; - if (isActive) line += chalk.hex(colors.success)(marker); + if (isActive) line += currentTheme.fg('success', marker); 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), '…'); - lines.push(chalk.hex(colors.textMuted)(` ${urlText}`)); + lines.push(currentTheme.fg('textMuted', ` ${urlText}`)); } return lines; diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 2a9cac262..ba14033f7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -16,14 +16,13 @@ import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; @@ -74,7 +73,6 @@ export class QuestionDialogComponent extends Container implements Focusable { focused = false; private readonly request: PendingQuestion; - private readonly colors: ColorPalette; private readonly onAnswer: (response: QuestionPanelResponse) => void; private readonly maxVisibleOptions: number; private readonly otherInput = new Input(); @@ -103,14 +101,12 @@ export class QuestionDialogComponent extends Container implements Focusable { constructor( request: PendingQuestion, onAnswer: (response: QuestionPanelResponse) => void, - colors: ColorPalette, maxVisibleOptions = 6, onToggleToolOutput?: () => void, ) { super(); this.request = request; this.onAnswer = onAnswer; - this.colors = colors; this.maxVisibleOptions = maxVisibleOptions; this.onToggleToolOutput = onToggleToolOutput; this.otherInput.onSubmit = (value) => { @@ -445,13 +441,12 @@ export class QuestionDialogComponent extends Container implements Focusable { const question = this.request.data.questions[questionIdx]; if (question === undefined) return []; - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const success = chalk.hex(colors.success); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const success = (text: string) => currentTheme.fg('success', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); @@ -501,13 +496,13 @@ export class QuestionDialogComponent extends Container implements Focusable { if (question.multi_select) { const checked = isSelected ? '✓' : ' '; prefix = ` [${checked}] `; - if (isSelected && isCursor) tone = (s) => success.bold(s); + if (isSelected && isCursor) tone = (s) => currentTheme.boldFg('success', s); else if (isSelected) tone = success; else if (isCursor) tone = accent; else tone = dim; } else if (isSelected && this.isAnswered(questionIdx)) { prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `; - tone = isCursor ? (s) => success.bold(s) : success; + tone = isCursor ? (s) => currentTheme.boldFg('success', s) : success; } else if (isCursor) { prefix = ` → [${String(num)}] `; tone = accent; @@ -543,17 +538,16 @@ export class QuestionDialogComponent extends Container implements Focusable { } private renderSubmitTab(width: number): string[] { - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const text = chalk.hex(colors.text); - const warning = chalk.hex(colors.warning); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const text = (t: string) => currentTheme.fg('text', t); + const warning = (text: string) => currentTheme.fg('warning', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); - lines.push(text.bold(` ${REVIEW_TITLE}`)); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); const reviewWarning = this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); if (reviewWarning !== undefined) { @@ -608,8 +602,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } private pushTabs(lines: string[]): void { - const dim = chalk.hex(this.colors.textDim); - const active = chalk.bgHex(this.colors.primary).hex(this.colors.text).bold; + const dim = (text: string) => currentTheme.fg('textDim', text); + const active = (text: string) => + currentTheme.bg('primary', currentTheme.boldFg('text', text)); const tabs: string[] = []; for (let i = 0; i < this.request.data.questions.length; i++) { @@ -620,7 +615,7 @@ export class QuestionDialogComponent extends Container implements Focusable { ? question.header : `Q${String(i + 1)}`; if (i === this.currentTab) tabs.push(active(` ${label} `)); - else if (this.isAnswered(i)) tabs.push(chalk.hex(this.colors.success)(`(✓) ${label}`)); + else if (this.isAnswered(i)) tabs.push(currentTheme.fg('success', `(✓) ${label}`)); else tabs.push(dim(`(○) ${label}`)); } @@ -750,14 +745,14 @@ export class QuestionDialogComponent extends Container implements Focusable { const checked = isSelected ? '✓' : ' '; const body = ` [${checked}] ${option.label}: `; prefix = isSelected - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } else { const body = ` → [${String(num)}] ${option.label}: `; prefix = isSelected && this.isAnswered(questionIdx) - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index b39ced4a3..8848d8b7c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -10,11 +10,9 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -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'; +import { currentTheme } from '#/tui/theme'; export interface SessionRow { readonly id: string; @@ -78,7 +76,6 @@ function singleLine(text: string): string { export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private colors: ColorPalette; private onSelect: (sessionId: string) => void; private onCancel: () => void; private maxVisibleSessions: number; @@ -91,7 +88,6 @@ export class SessionPickerComponent extends Container implements Focusable { sessions: SessionRow[]; loading: boolean; currentSessionId: string; - colors: ColorPalette; onSelect: (sessionId: string) => void; onCancel: () => void; onCtrlC?: () => void; @@ -102,7 +98,6 @@ export class SessionPickerComponent extends Container implements Focusable { this.sessions = opts.sessions; this.loading = opts.loading; this.currentSessionId = opts.currentSessionId; - this.colors = opts.colors; this.onSelect = opts.onSelect; this.onCancel = opts.onCancel; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; @@ -151,26 +146,26 @@ export class SessionPickerComponent extends Container implements Focusable { // the clamp in `render()` is what guarantees the renderer's invariant and // prevents the "Rendered line exceeds terminal width" crash (issue #240). private renderLines(width: number): string[] { - const colors = this.colors; - const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))]; if (this.loading) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)(truncateToWidth('Loading sessions...', width, ELLIPSIS)), + currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } if (this.sessions.length === 0) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS), ), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -180,7 +175,7 @@ export class SessionPickerComponent extends Container implements Focusable { const hintBudget = Math.max(0, width - labelWidth); const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); lines.push( - chalk.hex(colors.primary).bold(headerLabel) + chalk.hex(colors.textMuted)(shownHint), + currentTheme.boldFg('primary', headerLabel) + currentTheme.fg('textMuted', shownHint), ); lines.push(''); @@ -208,10 +203,10 @@ export class SessionPickerComponent extends Container implements Focusable { if (this.sessions.length > visibleSessions.length) { lines.push(''); const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`; - lines.push(chalk.hex(colors.textMuted)(truncateToWidth(footer, width, ELLIPSIS))); + lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -221,12 +216,12 @@ export class SessionPickerComponent extends Container implements Focusable { isSelected: boolean, isCurrent: boolean, ): string[] { - const colors = this.colors; 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 titleColor: 'primary' | 'text' = isSelected ? 'primary' : 'text'; + const titleStyle = (text: string) => + isSelected ? currentTheme.boldFg(titleColor, text) : currentTheme.fg(titleColor, text); const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; @@ -241,10 +236,10 @@ export class SessionPickerComponent extends Container implements Focusable { const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); - let header = chalk.hex(isSelected ? colors.primary : colors.textDim)(pointer + ' '); + let header = currentTheme.fg(isSelected ? 'primary' : 'textDim', pointer + ' '); header += titleStyle(shownTitle); - if (time.length > 0) header += ' ' + chalk.hex(colors.textDim)(time); - if (badge.length > 0) header += ' ' + chalk.hex(colors.success)(badge); + if (time.length > 0) header += ' ' + currentTheme.fg('textDim', time); + if (badge.length > 0) header += ' ' + currentTheme.fg('success', badge); const card: string[] = [header]; // Session id is rendered in full at normal widths (the final clamp in @@ -261,22 +256,23 @@ export class SessionPickerComponent extends Container implements Focusable { if (idLineWidth + metaGapWidth + dirWidth <= width) { card.push( indent + - chalk.hex(colors.textMuted)(fullId) + - chalk.hex(colors.textDim)(metaGap) + - chalk.hex(colors.textMuted)(aliasedDir), + currentTheme.fg('textMuted', fullId) + + currentTheme.fg('textDim', metaGap) + + currentTheme.fg('textMuted', aliasedDir), ); } else { // Not enough room for both on one line — keep the id intact and put the // directory on the next line (left-truncated only if it still doesn't fit). card.push( indent + - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), ), ); const dirBudget = Math.max(8, width - indentWidth); const dir = truncatePathLeft(aliasedDir, dirBudget); - card.push(indent + chalk.hex(colors.textMuted)(dir)); + card.push(indent + currentTheme.fg('textMuted', dir)); } const rawPrompt = session.last_prompt?.trim(); @@ -285,7 +281,7 @@ export class SessionPickerComponent extends Container implements Focusable { const promptMarkerWidth = visibleWidth(promptMarker); const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); - const promptLine = indent + chalk.hex(colors.textDim)(promptMarker + promptText); + const promptLine = indent + currentTheme.fg('textDim', promptMarker + promptText); card.push(promptLine); } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 3e6e42691..81e4b8d12 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - export type SettingsSelection = | 'model' | 'theme' @@ -62,7 +60,6 @@ function isSettingsSelection(value: string): value is SettingsSelection { } export interface SettingsSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (value: SettingsSelection) => void; readonly onCancel: () => void; } @@ -72,7 +69,6 @@ export class SettingsSelectorComponent extends ChoicePickerComponent { super({ title: 'Settings', options: [...SETTINGS_OPTIONS], - colors: opts.colors, onSelect: (value) => { if (isSettingsSelection(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 875905a61..38c1da2bb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -6,10 +6,9 @@ import { type Component, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -22,7 +21,6 @@ export interface StartPermissionOption { - readonly colors: ColorPalette; readonly title: string; readonly noticeLines: readonly string[]; readonly options: readonly StartPermissionOption[]; @@ -59,19 +57,18 @@ export class StartPermissionPromptComponent { - if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return strong(part); - return base(part); + if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return currentTheme.boldFg('textStrong', part); + return currentTheme.fg(baseToken, part); }) .join(''); } diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts index cbb910b04..b4da0d851 100644 --- a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -1,5 +1,3 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, @@ -8,7 +6,6 @@ import { export type SwarmStartPermissionChoice = 'auto' | 'manual'; export interface SwarmStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly onSelect: (choice: SwarmStartPermissionChoice) => void; readonly onCancel: () => void; } @@ -37,7 +34,6 @@ const NOTICE_LINES = [ export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: SwarmStartPermissionPromptOptions) { super({ - colors: opts.colors, title: 'Start a swarm task with approvals on?', noticeLines: NOTICE_LINES, options: OPTIONS, diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 62cd2afec..747072a5c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -22,9 +22,8 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { ModelSelectorComponent, @@ -41,7 +40,6 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinking: boolean; - readonly colors: ColorPalette; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -133,15 +131,13 @@ export class TabbedModelSelectorComponent extends Container implements Focusable * (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); + ? currentTheme.bg('primary', currentTheme.boldFg('text', cell)) + : currentTheme.fg('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]!; @@ -198,10 +194,10 @@ export class TabbedModelSelectorComponent extends Container implements Focusable const hasLeft = start > 0; const hasRight = end < segments.length; - let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' '; strip += segments.slice(start, end).join(' '); if (hasRight) { - strip += chalk.hex(colors.textMuted)(' >'); + strip += currentTheme.fg('textMuted', ' >'); } return strip; } @@ -251,7 +247,6 @@ function makeSelector( currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinking: opts.currentThinking, - colors: opts.colors, searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index b9a525ff5..a1718a5eb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -19,9 +19,8 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -30,7 +29,6 @@ export interface TaskOutputViewerProps { readonly taskId: string; readonly info: BackgroundTaskInfo | undefined; readonly output: string; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -43,17 +41,17 @@ const STATUS_LABEL: Record = { lost: 'lost', }; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -183,18 +181,17 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Task output '); - const id = chalk.hex(colors.text).bold(this.props.taskId); + const title = currentTheme.boldFg('primary', ' Task output '); + const id = currentTheme.boldFg('text', this.props.taskId); const info = this.props.info; const segments: string[] = []; if (info !== undefined) { - segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); if (info.kind === 'process' && info.exitCode !== null) { - segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); + segments.push(currentTheme.fg('textMuted', `exit ${String(info.exitCode)}`)); } if (info.description && info.description.length > 0) { - segments.push(chalk.hex(colors.textMuted)(info.description)); + segments.push(currentTheme.fg('textMuted', info.description)); } } const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); @@ -202,9 +199,6 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; - // Reserve 1 col for left/right border each, 1 col for left padding. const innerWidth = Math.max(1, width - 4); @@ -214,24 +208,23 @@ export class TaskOutputViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; // inside top + bottom border - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.lines[lineIndex] ?? ''; - const inner = fitExactly(chalk.hex(colors.text)(raw), innerWidth); - out.push(chalk.hex(stroke)('│ ') + inner + chalk.hex(stroke)(' │')); + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.lines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -241,7 +234,8 @@ export class TaskOutputViewer extends Container implements Focusable { const lineFrom = this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 44c9f192b..7863c493c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -23,10 +23,9 @@ import { type Focusable, } from '@earendil-works/pi-tui'; 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 { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -40,7 +39,6 @@ export interface TasksBrowserProps { readonly tailOutput: string | undefined; readonly tailLoading: boolean; readonly flashMessage: string | undefined; - readonly colors: ColorPalette; readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; readonly onRefresh: () => void; @@ -74,17 +72,17 @@ const LIST_COL_MIN = 28; const LIST_COL_MAX = 44; const LIST_COL_RATIO = 0.32; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -330,36 +328,35 @@ export class TasksBrowserApp extends Container implements Focusable { // ── header / footer ────────────────────────────────────────────────── private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' TASK BROWSER '); - const filterText = chalk.hex(colors.textMuted)( + const title = currentTheme.boldFg('primary', ' TASK BROWSER '); + const filterText = currentTheme.fg( + 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); const counts = countByStatus(this.props.tasks); const countSegments: string[] = []; if (counts.running > 0) - countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); + countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); if (counts.completed > 0) - countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); + countSegments.push(currentTheme.fg('textDim', ` ${String(counts.completed)} completed `)); if (counts.terminalFailed > 0) countSegments.push( - chalk.hex(colors.error)(` ${String(counts.terminalFailed)} interrupted `), + currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(this.props.tasks.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); } private renderFooter(width: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); if (this.pendingStopTaskId !== undefined) { - const warn = (text: string): string => chalk.hex(colors.warning).bold(text); + const warn = (text: string): string => currentTheme.boldFg('warning', text); const line = - ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + + ` ${warn('Stop')} ${currentTheme.fg('text', this.pendingStopTaskId)}? ` + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; return fitExactly(line, width); } @@ -375,7 +372,7 @@ export class TasksBrowserApp extends Container implements Focusable { const left = parts.join(' '); const flash = this.props.flashMessage; if (flash !== undefined && flash.length > 0) { - const flashStyled = chalk.hex(colors.warning)(` ${flash} `); + const flashStyled = currentTheme.fg('warning', ` ${flash} `); const total = visibleWidth(left) + visibleWidth(flashStyled); if (total <= width) { return left + ' '.repeat(width - total) + flashStyled; @@ -402,29 +399,28 @@ export class TasksBrowserApp extends Container implements Focusable { for (let i = 0; i < height; i++) out.push(' '.repeat(width)); return out; } - const stroke = this.props.colors.primary; const innerWidth = width - 2; const innerHeight = height - 2; - const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title); + const titleStyled = currentTheme.boldFg('textStrong', title); const titleWidth = visibleWidth(titleStyled); const titleSegment = `─ ${titleStyled} `; const titleSegmentWidth = visibleWidth(titleSegment); const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth - ? chalk.hex(stroke)('─ ') + + ? currentTheme.fg('primary', '─ ') + titleStyled + ' ' + - chalk.hex(stroke)('─'.repeat(remainingDashes)) - : chalk.hex(stroke)('─'.repeat(innerWidth)); - const top = chalk.hex(stroke)('┌') + topMid + chalk.hex(stroke)('┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(innerWidth) + '┘'); + currentTheme.fg('primary', '─'.repeat(remainingDashes)) + : currentTheme.fg('primary', '─'.repeat(innerWidth)); + const top = currentTheme.fg('primary', '┌') + topMid + currentTheme.fg('primary', '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(innerWidth) + '┘'); const lines: string[] = [top]; for (let i = 0; i < innerHeight; i++) { const inner = content[i] ?? ''; - lines.push(chalk.hex(stroke)('│') + fitExactly(inner, innerWidth) + chalk.hex(stroke)('│')); + lines.push(currentTheme.fg('primary', '│') + fitExactly(inner, innerWidth) + currentTheme.fg('primary', '│')); } lines.push(bottom); return lines; @@ -441,7 +437,7 @@ export class TasksBrowserApp extends Container implements Focusable { this.props.filter === 'active' ? 'No active tasks. Tab = show all.' : 'No background tasks in this session.'; - const lines: string[] = [chalk.hex(this.props.colors.textMuted)(empty)]; + const lines: string[] = [currentTheme.fg('textMuted', empty)]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame(title, lines, width, height); } @@ -462,24 +458,23 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { - const colors = this.props.colors; const pointer = selected ? `${SELECT_POINTER} ` : ' '; - const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); + const pointerStyled = currentTheme.fg(selected ? 'primary' : 'textDim', pointer); const idColor = selected - ? colors.primary + ? 'primary' : task.kind === 'agent' - ? colors.success + ? 'success' : task.kind === 'question' - ? colors.warning - : colors.accent; + ? 'warning' + : 'accent'; const idText = selected - ? chalk.hex(idColor).bold(task.taskId) - : chalk.hex(idColor)(task.taskId); + ? currentTheme.boldFg(idColor, task.taskId) + : currentTheme.fg(idColor, task.taskId); const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); const status = STATUS_LABEL[task.status]; - const statusBadge = chalk.hex(statusColor(colors, task.status))(status); + const statusBadge = currentTheme.fg(statusColor(task.status), status); const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; const prefixWidth = visibleWidth(prefix); @@ -491,7 +486,7 @@ export class TasksBrowserApp extends Container implements Focusable { (task.kind === 'process' ? singleLine(task.command) : '') || '(no description)'; const desc = truncateToWidth(description, descBudget, ELLIPSIS); - return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); + return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth); } private adjustScroll(visibleRows: number): void { @@ -523,22 +518,21 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderDetailFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const empty = chalk.hex(colors.textMuted)('Select a task from the list.'); + const empty = currentTheme.fg('textMuted', 'Select a task from the list.'); const lines: string[] = [empty]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } - const label = (text: string): string => chalk.hex(colors.textMuted)(text.padEnd(14)); - const value = (text: string): string => chalk.hex(colors.text)(text); + const label = (text: string): string => currentTheme.fg('textMuted', text.padEnd(14)); + const value = (text: string): string => currentTheme.fg('text', text); const lines: string[] = [ `${label('Task ID:')}${value(task.taskId)}`, - `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, + `${label('Status:')}${currentTheme.fg(statusColor(task.status), STATUS_LABEL[task.status])}`, `${label('Description:')}${value(singleLine(task.description) || '—')}`, ]; if (task.kind === 'process' && task.command && task.command !== task.description) { @@ -551,9 +545,9 @@ export class TasksBrowserApp extends Container implements Focusable { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } if (task.kind === 'question') { - lines.push(`${label('Questions:')}${chalk.hex(colors.textMuted)(String(task.questionCount))}`); + lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); if (task.toolCallId !== undefined) { - lines.push(`${label('Tool call:')}${chalk.hex(colors.textMuted)(task.toolCallId)}`); + lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); } } const timing = @@ -562,26 +556,25 @@ export class TasksBrowserApp extends Container implements Focusable { : task.endedAt !== null && task.endedAt !== undefined ? `finished ${formatRelativeTime(task.endedAt)}` : ''; - if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); + if (timing.length > 0) lines.push(`${label('Time:')}${currentTheme.fg('textMuted', timing)}`); if (task.kind === 'process' && task.pid > 0) { - lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); + lines.push(`${label('Pid:')}${currentTheme.fg('textMuted', String(task.pid))}`); } if (task.kind === 'process' && task.exitCode !== null) { - lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); + lines.push(`${label('Exit code:')}${currentTheme.fg('textMuted', String(task.exitCode))}`); } if (task.stopReason !== undefined && task.stopReason.length > 0) { - lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); + lines.push(`${label('Reason:')}${currentTheme.fg('textMuted', task.stopReason)}`); } while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } private renderPreviewFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const lines: string[] = [chalk.hex(colors.textMuted)('No task selected.')]; + const lines: string[] = [currentTheme.fg('textMuted', 'No task selected.')]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Preview Output', lines, width, height); } @@ -594,7 +587,7 @@ export class TasksBrowserApp extends Container implements Focusable { const rawLines = body.split('\n'); const tailLines = rawLines.slice(-innerHeight); - const styled = tailLines.map((line) => chalk.hex(colors.textDim)(line)); + const styled = tailLines.map((line) => currentTheme.fg('textDim', line)); while (styled.length < innerHeight) styled.push(''); return this.renderFrame('Preview Output', styled, width, height); } @@ -603,7 +596,8 @@ export class TasksBrowserApp extends Container implements Focusable { private renderTooSmall(width: number, rows: number): string[] { const lines: string[] = []; - const msg = chalk.hex(this.props.colors.error)( + const msg = currentTheme.fg( + 'error', `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, ); lines.push(fitExactly(msg, width)); diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts index 8d6381c61..ecd3953fd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -1,7 +1,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; -import type { Theme } from '#/tui/theme/index'; +import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; +import type { ThemeName } from '#/tui/theme/index'; const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto (match terminal)' }, @@ -9,26 +9,25 @@ const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'light', label: 'Light' }, ]; -function isThemeChoice(value: string): value is Theme { - return value === 'auto' || value === 'dark' || value === 'light'; -} - export interface ThemeSelectorOptions { - readonly currentValue: Theme; - readonly colors: ColorPalette; - readonly onSelect: (theme: Theme) => void; + readonly currentValue: ThemeName; + readonly onSelect: (theme: ThemeName) => void; readonly onCancel: () => void; } export class ThemeSelectorComponent extends ChoicePickerComponent { constructor(opts: ThemeSelectorOptions) { + const customThemes = listCustomThemesSync(); + const options: ChoiceOption[] = [ + ...THEME_OPTIONS, + ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ]; super({ title: 'Select theme', - options: [...THEME_OPTIONS], + options, currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { - if (isThemeChoice(value)) opts.onSelect(value); + opts.onSelect(value); }, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts index a57ca7b86..35055e084 100644 --- a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ { value: 'on', @@ -17,7 +15,6 @@ const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ export interface UpdatePreferenceSelectorOptions { readonly currentValue: boolean; - readonly colors: ColorPalette; readonly onSelect: (value: boolean) => void; readonly onCancel: () => void; } @@ -28,7 +25,6 @@ export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { title: 'Automatic updates', options: [...UPDATE_PREFERENCE_OPTIONS], currentValue: opts.currentValue ? 'on' : 'off', - colors: opts.colors, onSelect: (value) => { opts.onSelect(value === 'on'); }, diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 6966dc50e..43b12ce1d 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -3,9 +3,8 @@ */ import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences @@ -124,26 +123,13 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; - /** - * `colors` is the live `ColorPalette` reference — the host mutates it - * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so - * reading `this.colors.` at render time always sees the - * current theme without any setter plumbing. The `EditorTheme` that - * pi-tui's `Editor` requires is derived from the same palette, and - * `paddingX: 2` reserves the two leading columns where `render()` - * paints the terminal-style `> ` prompt — both are implementation - * details, not caller knobs. - */ - constructor( - tui: TUI, - private readonly colors: ColorPalette, - ) { + constructor(tui: TUI) { // paddingX: 4 reserves column 0 for the left vertical border (│), // column 1 as a single space between border and prompt, column 2 for // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - super(tui, createEditorTheme(colors), { paddingX: 4 }); + super(tui, createEditorTheme(), { paddingX: 4 }); } private expandPasteMarkerAtCursor(): boolean { @@ -195,7 +181,7 @@ export class CustomEditor extends Editor { // are not a thing in practice. const original = lines[firstContentIdx]; if (original !== undefined) { - const highlighted = highlightFirstSlashToken(original, this.colors.primary); + const highlighted = highlightFirstSlashToken(original, 'primary'); if (highlighted !== undefined) { lines[firstContentIdx] = highlighted; } @@ -342,7 +328,7 @@ export class CustomEditor extends Editor { * locate `/` via visible-index math so ANSI pass-through survives. * Returns `undefined` if no token is found. */ -export function highlightFirstSlashToken(line: string, hex: string): string | undefined { +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { const visible = stripSgr(line); const slashIdx = visible.indexOf('/'); if (slashIdx < 0) return undefined; @@ -364,7 +350,7 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un if (visibleToken === '/goal') { ranges.push(...goalCommandPathRanges(visible, endVisible)); } - return highlightVisibleRanges(line, ranges, hex); + return highlightVisibleRanges(line, ranges, token); } function goalCommandPathRanges( @@ -402,7 +388,7 @@ function isTokenSpace(ch: string | undefined): boolean { function highlightVisibleRanges( line: string, ranges: Array<{ start: number; end: number }>, - hex: string, + token: 'primary', ): string { let out = ''; let rawCursor = 0; @@ -410,7 +396,7 @@ function highlightVisibleRanges( const rawStart = mapVisibleIdxToRaw(line, range.start); const rawEnd = mapVisibleIdxToRaw(line, range.end); out += line.slice(rawCursor, rawStart); - out += chalk.hex(hex).bold(line.slice(rawStart, rawEnd)); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); rawCursor = rawEnd; } return out + line.slice(rawCursor); diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 5a978723d..00ede4a26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type DiffLineKind = 'context' | 'add' | 'delete'; @@ -20,14 +20,15 @@ interface DiffStyles { meta: (s: string) => string; } -function makeDiffStyles(colors: ColorPalette): DiffStyles { +function makeDiffStyles(): DiffStyles { + const palette = currentTheme.palette; return { - add: (s) => chalk.hex(colors.diffAdded)(s), - del: (s) => chalk.hex(colors.diffRemoved)(s), - addBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - delBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - meta: (s) => chalk.hex(colors.diffMeta)(s), + add: (s) => chalk.hex(palette.diffAdded)(s), + del: (s) => chalk.hex(palette.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(palette.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(palette.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(palette.diffGutter)(s), + meta: (s) => chalk.hex(palette.diffMeta)(s), }; } @@ -108,13 +109,12 @@ export function renderDiffLines( oldText: string, newText: string, path: string, - colors: ColorPalette, isIncomplete: boolean = false, oldStart?: number, newStart?: number, maxLines?: number, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); const changedLines = diffLines.filter((l) => l.kind !== 'context'); const added = changedLines.filter((l) => l.kind === 'add').length; @@ -234,10 +234,9 @@ export function renderDiffLinesClustered( oldText: string, newText: string, path: string, - colors: ColorPalette, opts: ClusteredDiffOptions = {}, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index 86253582f..d95eeb3f9 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -13,43 +13,54 @@ */ import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; const MAX_IMAGE_ROWS = 12; const MAX_IMAGE_WIDTH = 40; export class ImageThumbnail extends Container { - constructor(attachment: ImageAttachment, colors: ColorPalette) { - super(); + private readonly attachment: ImageAttachment; + constructor(attachment: ImageAttachment) { + super(); + this.attachment = attachment; + this.buildChildren(); + } + + private buildChildren(): void { + this.clear(); const caps = getCapabilities(); const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; if (!supportsInline) { - // Non-graphic terminal — show the placeholder text in dim cyan so + // Non-graphic terminal — show the placeholder text in accent colour so // it's clearly an attachment reference but doesn't shout. - this.addChild(new Text(chalk.hex(colors.accent)(attachment.placeholder), 0, 0)); + this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0)); return; } const theme: ImageTheme = { - fallbackColor: (s: string) => chalk.hex(colors.textDim)(s), + fallbackColor: (s: string) => currentTheme.fg('textDim', s), }; - const base64 = Buffer.from(attachment.bytes).toString('base64'); + const base64 = Buffer.from(this.attachment.bytes).toString('base64'); const image = new Image( base64, - attachment.mime, + this.attachment.mime, theme, { maxHeightCells: MAX_IMAGE_ROWS, maxWidthCells: MAX_IMAGE_WIDTH, - filename: attachment.placeholder, + filename: this.attachment.placeholder, }, - { widthPx: attachment.width, heightPx: attachment.height }, + { widthPx: this.attachment.width, heightPx: this.attachment.height }, ); this.addChild(image); } + + override invalidate(): void { + this.buildChildren(); + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 501d1c936..47decf64e 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -17,10 +17,9 @@ import type { TUI } from '@earendil-works/pi-tui'; import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; @@ -37,11 +36,9 @@ export class AgentGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -136,15 +133,14 @@ export class AgentGroupComponent extends Container { } private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { - const colors = this.colors; const total = snapshots.length; const done = snapshots.filter((s) => s.phase === 'done').length; const failed = snapshots.filter((s) => s.phase === 'failed').length; const finished = done + failed; const allDone = finished === total; const bullet = allDone - ? chalk.hex(colors.success)(STATUS_BULLET) - : chalk.hex(colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -155,7 +151,7 @@ export class AgentGroupComponent extends Container { const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); const tail = formatHeaderTail(totalTools, totalTokens); - return `${bullet}${chalk.hex(colors.primary).bold(headerLabel)}${tail}`; + return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } let headerText = `Running ${String(total)} agents`; @@ -168,19 +164,18 @@ export class AgentGroupComponent extends Container { if (running > 0) parts.push(`${String(running)} running`); headerText = `Running ${String(total)} agents (${parts.join(', ')})`; } - return `${bullet}${chalk.hex(colors.primary).bold(headerText)}`; + return `${bullet}${currentTheme.boldFg('primary', headerText)}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); // First-level branch line. const branch1 = isLast ? '└─' : '├─'; const agentType = snap.agentName ?? 'agent'; const desc = snap.toolCallDescription || '(no description)'; - const tail = formatLineTail(snap, colors); - const namePart = chalk.hex(colors.primary)(agentType); + const tail = formatLineTail(snap); + const namePart = currentTheme.fg('primary', agentType); const descPart = dim(`· ${desc}`); const stats = formatStats(snap); const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; @@ -191,7 +186,7 @@ export class AgentGroupComponent extends Container { if (snap.phase === 'failed') { // Show one error line; error messages can be long. const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; - const errStr = chalk.hex(colors.error)(`Error: ${errLine}`); + const errStr = currentTheme.fg('error', `Error: ${errLine}`); this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); return; } @@ -206,6 +201,16 @@ export class AgentGroupComponent extends Container { } /** Releases throttle timers so destroyed components cannot refresh later. */ + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + dispose(): void { if (this.throttleTimer !== null) { clearTimeout(this.throttleTimer); @@ -218,27 +223,28 @@ export class AgentGroupComponent extends Container { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; return dim(`${tools}${tokens}`); } -function formatLineTail(snap: ToolCallSubagentSnapshot, colors: ColorPalette): string { +function formatLineTail(snap: ToolCallSubagentSnapshot): string { + const dim = (text: string) => currentTheme.dim(text); if (snap.phase === 'done') { - return chalk.dim(' · ') + chalk.hex(colors.success)('✓ Completed'); + return dim(' · ') + currentTheme.fg('success', '✓ Completed'); } if (snap.phase === 'failed') { - return chalk.dim(' · ') + chalk.hex(colors.error)('✗ Failed'); + return dim(' · ') + currentTheme.fg('error', '✗ Failed'); } if (snap.phase === 'backgrounded') { - return chalk.dim(' · ◐ backgrounded'); + return dim(' · ◐ backgrounded'); } return ''; } function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); const parts: string[] = []; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); if (tokens > 0) parts.push(formatTokens(tokens)); diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index c7b8cae9c..173be9951 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -6,6 +6,7 @@ import { type AgentSwarmProgressEstimatorPhase, } from '#/tui/components/messages/agent-swarm-progress-estimator'; import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import { gradientText } from '#/tui/theme/gradient-text'; @@ -169,7 +170,6 @@ export interface AgentSwarmGridLayout { export interface AgentSwarmProgressOptions { readonly description: string; - readonly colors: ColorPalette; readonly requestRender?: () => void; readonly availableGridHeight?: () => number | undefined; } @@ -188,7 +188,6 @@ export class AgentSwarmProgressComponent implements Component { private members: AgentSwarmMember[]; private readonly progressEstimator = new AgentSwarmProgressEstimator(); private description: string; - private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; private inputComplete = false; @@ -202,12 +201,16 @@ export class AgentSwarmProgressComponent implements Component { constructor(options: AgentSwarmProgressOptions) { this.description = options.description; - this.colors = options.colors; this.requestRender = options.requestRender; this.availableGridHeight = options.availableGridHeight; this.members = []; } + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + dispose(): void { if (this.timer === undefined) return; clearInterval(this.timer); diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 1be89b2ca..cb062b575 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,24 +5,20 @@ * to align after the bullet. */ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component } from '@earendil-works/pi-tui'; import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; export class AssistantMessageComponent implements Component { private contentContainer: Container; - private markdownTheme: MarkdownTheme; - private bulletColor: string; private lastText = ''; private showBullet: boolean; - constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { - this.markdownTheme = markdownTheme; - this.bulletColor = colors.roleAssistant; + constructor(showBullet: boolean = true) { this.showBullet = showBullet; this.contentContainer = new Container(); } @@ -37,12 +33,20 @@ export class AssistantMessageComponent implements Component { this.lastText = displayText; this.contentContainer.clear(); if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme)); + this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createMarkdownTheme())); } } invalidate(): void { - this.contentContainer.invalidate?.(); + // Markdown caches ANSI colour codes keyed on (text, width). When the + // theme changes the cached strings contain stale colours, so we rebuild + // the Markdown child with the new theme. + this.contentContainer.clear(); + if (this.lastText.trim().length > 0) { + this.contentContainer.addChild( + new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()), + ); + } } render(width: number): string[] { @@ -55,7 +59,7 @@ export class AssistantMessageComponent implements Component { const lines: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { const p = - i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : MESSAGE_INDENT; + i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } return lines; diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index c1086f805..aaadd1514 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,34 +1,31 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { BackgroundAgentStatusData } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { - constructor( - private readonly data: BackgroundAgentStatusData, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly data: BackgroundAgentStatusData) {} invalidate(): void {} render(width: number): string[] { - const tone = + const tone: keyof ColorPalette = this.data.phase === 'started' - ? this.colors.primary + ? 'primary' : this.data.phase === 'completed' - ? this.colors.success - : this.colors.error; + ? 'success' + : 'error'; const bullet = - this.data.phase === 'failed' ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET); + this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); const text = - chalk.hex(tone)(this.data.headline) + + currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 - ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) + ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); const textComponent = new Text(text, 0, 0); diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 075ce3378..0fa31794a 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,36 +1,40 @@ import type { Component } from '@earendil-works/pi-tui'; import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { CronTranscriptData } from '#/tui/types'; export class CronMessageComponent implements Component { private readonly spacer = new Spacer(1); + private readonly data: CronTranscriptData; private readonly title: string; private readonly detail: string | undefined; - private readonly titleColor: string; private readonly promptText: Text; + private readonly prompt: string; constructor( prompt: string, data: CronTranscriptData, - private readonly colors: ColorPalette, ) { const missed = data.missedCount !== undefined; + this.data = data; this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; this.detail = cronDetail(data); - this.titleColor = data.stale === true || missed ? colors.warning : colors.accent; - this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0); + this.prompt = prompt; + this.promptText = new Text(currentTheme.fg('text', prompt), 0, 0); } invalidate(): void { + this.promptText.setText(currentTheme.fg('text', this.prompt)); this.promptText.invalidate(); } render(width: number): string[] { - const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET); + const missed = this.data.missedCount !== undefined; + const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent'; + const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET); const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, width - bulletWidth); const lines: string[] = []; @@ -39,11 +43,11 @@ export class CronMessageComponent implements Component { lines.push(line); } - const title = chalk.hex(this.titleColor).bold(this.title); + const title = currentTheme.boldFg(titleToken, this.title); lines.push(`${bullet}${title}`); if (this.detail !== undefined) { - lines.push(`${' '.repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`); + lines.push(`${' '.repeat(bulletWidth)}${currentTheme.fg('textDim', this.detail)}`); } const promptLines = this.promptText.render(contentWidth); diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 97bd2c2c8..1aee9969b 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -9,10 +9,10 @@ import type { Component } from '@earendil-works/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const HEAD_INDENT = ' '; const DETAIL_INDENT = ' '; @@ -21,7 +21,7 @@ type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system'; interface GoalMarkerOptions { readonly marker?: string; - readonly textHex?: string; + readonly textToken?: ColorToken; readonly expandable?: boolean; readonly indent?: string; readonly leadingBlank?: boolean; @@ -30,7 +30,7 @@ interface GoalMarkerOptions { export class GoalMarkerComponent implements Component { private expanded = false; private readonly marker: string; - private readonly textHex: string; + private readonly textToken: ColorToken; private readonly expandable: boolean; private readonly indent: string; private readonly leadingBlank: boolean; @@ -38,12 +38,11 @@ export class GoalMarkerComponent implements Component { constructor( private readonly headline: string, private readonly detail: string | undefined, - private readonly colors: ColorPalette, - private readonly accentHex: string, + private readonly accentToken: ColorToken, options: GoalMarkerOptions = {}, ) { this.marker = options.marker ?? '◦'; - this.textHex = options.textHex ?? colors.textDim; + this.textToken = options.textToken ?? 'textDim'; this.expandable = options.expandable ?? true; this.indent = options.indent ?? HEAD_INDENT; this.leadingBlank = options.leadingBlank ?? false; @@ -56,8 +55,8 @@ export class GoalMarkerComponent implements Component { } render(width: number): string[] { - const dot = chalk.hex(this.accentHex)(this.marker); - const head = chalk.hex(this.textHex)(this.headline); + const dot = currentTheme.fg(this.accentToken, this.marker); + const head = currentTheme.fg(this.textToken, this.headline); const hasDetail = this.detail !== undefined && this.detail.length > 0; if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); @@ -66,13 +65,13 @@ export class GoalMarkerComponent implements Component { } if (!this.expanded) { return this.withLeadingBlank([ - `${this.indent}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`, + `${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`, ]); } const out = [`${this.indent}${dot} ${head}`]; const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); for (const line of wrap(this.detail!, wrapWidth)) { - out.push(DETAIL_INDENT + chalk.hex(this.colors.textDim)(line)); + out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); } return this.withLeadingBlank(out); } @@ -89,17 +88,15 @@ export class GoalMarkerComponent implements Component { */ export function buildGoalMarker( change: GoalChange, - colors: ColorPalette, expanded: boolean, actor?: GoalMarkerActor, ): GoalMarkerComponent | null { - const spec = markerSpec(change, colors, actor); + const spec = markerSpec(change, actor); if (spec === null) return null; const marker = new GoalMarkerComponent( spec.headline, spec.detail ?? change.reason, - colors, - spec.accentHex, + spec.accentToken, spec.options, ); marker.setExpanded(expanded); @@ -108,23 +105,22 @@ export function buildGoalMarker( function markerSpec( change: GoalChange, - colors: ColorPalette, actor?: GoalMarkerActor, ): { headline: string; - accentHex: string; + accentToken: ColorToken; detail?: string | undefined; options?: GoalMarkerOptions | undefined; } | null { if (change.kind === 'lifecycle') { switch (change.status) { case 'paused': - return prominentMarker(pausedHeadline(change.reason, actor), colors.warning); + return prominentMarker(pausedHeadline(change.reason, actor), 'warning'); case 'active': - return prominentMarker(resumedHeadline(actor), colors.primary); + return prominentMarker(resumedHeadline(actor), 'primary'); case 'blocked': // The system stopped pursuing the goal; resumable via `/goal resume`. - return { headline: 'Goal blocked', accentHex: colors.warning }; + return { headline: 'Goal blocked', accentToken: 'warning' }; default: return null; } @@ -132,14 +128,14 @@ function markerSpec( return null; // completion -> posts its own message, not a marker } -function prominentMarker(headline: string, accentHex: string) { +function prominentMarker(headline: string, accentToken: ColorToken) { return { headline, - accentHex, + accentToken, detail: undefined, options: { marker: STATUS_BULLET.trimEnd(), - textHex: accentHex, + textToken: accentToken, expandable: false, indent: '', leadingBlank: true, diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index 48be7b456..4ebd9d1ee 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -16,11 +16,11 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text, visibleWidth } from '@earendil-works/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { formatTokenCount } from '#/utils/usage/usage-format'; import { formatGoalElapsed } from './goal-format'; import { UsagePanelComponent } from './usage-panel'; @@ -30,9 +30,9 @@ const MAX_OBJECTIVE_LINES = 6; const MAX_CRITERION_LINES = 3; const LABEL_WIDTH = 11; -function renderLifecycleLine(label: string, colors: ColorPalette): string[] { - const marker = chalk.hex(colors.primary).bold(STATUS_BULLET); - const text = chalk.hex(colors.primary).bold(label); +function renderLifecycleLine(label: string): string[] { + const marker = currentTheme.boldFg('primary', STATUS_BULLET); + const text = currentTheme.boldFg('primary', label); return ['', marker + text]; } @@ -42,33 +42,25 @@ function renderLifecycleLine(label: string, colors: ColorPalette): string[] { * change in the transcript. */ export class GoalSetMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} render(_width: number): string[] { - return renderLifecycleLine('Goal set', this.colors); + return renderLifecycleLine('Goal set'); } } export class UpcomingGoalAddedMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} render(_width: number): string[] { return renderLifecycleLine( 'Upcoming goal added. It will start after the current goal is complete.', - this.colors, ); } } export class GoalCompletionMessageComponent implements Component { - constructor( - private readonly message: string, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly message: string) {} invalidate(): void {} @@ -76,12 +68,12 @@ export class GoalCompletionMessageComponent implements Component { const [headline = '', ...details] = this.message.trim().split(/\r?\n/); if (headline.length === 0) return []; - const bullet = chalk.hex(this.colors.success).bold(STATUS_BULLET); + const bullet = currentTheme.boldFg('success', STATUS_BULLET); const bulletWidth = visibleWidth(STATUS_BULLET); const contentWidth = Math.max(1, width - bulletWidth); const lines: string[] = ['']; - const headlineText = new Text(chalk.hex(this.colors.success).bold(headline), 0, 0); + const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); const headlineLines = headlineText.render(contentWidth); for (let i = 0; i < headlineLines.length; i += 1) { lines.push((i === 0 ? bullet : MESSAGE_INDENT) + headlineLines[i]); @@ -89,7 +81,7 @@ export class GoalCompletionMessageComponent implements Component { const detailText = details.join('\n').trim(); if (detailText.length > 0) { - const detailLines = new Text(chalk.hex(this.colors.textDim)(detailText), 0, 0).render( + const detailLines = new Text(currentTheme.fg('textDim', detailText), 0, 0).render( contentWidth, ); for (const line of detailLines) { @@ -102,35 +94,30 @@ export class GoalCompletionMessageComponent implements Component { } export class GoalStatusMessageComponent implements Component { - constructor( - private readonly goal: GoalSnapshot, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly goal: GoalSnapshot) {} invalidate(): void {} render(width: number): string[] { - const lines = buildGoalReportLines({ colors: this.colors, goal: this.goal }); - const panel = new UsagePanelComponent(lines, this.colors.primary, goalPanelTitle(this.goal)); + const panel = new UsagePanelComponent( + () => buildGoalReportLines(this.goal), + 'primary', + goalPanelTitle(this.goal), + ); return ['', ...panel.render(width)]; } } -export interface GoalReportOptions { - readonly colors: ColorPalette; - readonly goal: GoalSnapshot; -} - /** Box title, e.g. ` Goal · active `. */ export function goalPanelTitle(goal: GoalSnapshot): string { return ` Goal · ${goal.status} `; } -export function buildGoalReportLines(options: GoalReportOptions): string[] { - const { colors, goal } = options; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const bar = chalk.hex(statusHex(goal.status, colors)); +export function buildGoalReportLines(goal: GoalSnapshot): string[] { + const statusColor = statusToken(goal.status); + const bar = (s: string) => currentTheme.fg(statusColor, s); + const value = (s: string) => currentTheme.fg('text', s); + const muted = (s: string) => currentTheme.fg('textDim', s); // `complete` is the terminal outcome (the completion card); everything else // (active / paused / blocked) is a persisted, resumable goal that still shows // its stop condition. A reason is worth surfacing for stopped / complete states. @@ -157,7 +144,7 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] { lines.push( row( 'Status', - chalk.hex(statusHex(goal.status, colors))(goal.status) + + currentTheme.fg(statusColor, goal.status) + (reason !== undefined ? muted(` — ${reason}`) : ''), ), ); @@ -192,16 +179,16 @@ function formatStopRow(goal: GoalSnapshot): string | null { return parts.length > 0 ? parts.join(', ') : null; } -function statusHex(status: GoalStatus, colors: ColorPalette): string { +function statusToken(status: GoalStatus): ColorToken { switch (status) { case 'active': - return colors.primary; + return 'primary'; case 'complete': - return colors.success; + return 'success'; case 'blocked': - return colors.warning; + return 'warning'; case 'paused': - return colors.textDim; + return 'textDim'; } } diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts index 7f7ba6003..416cf6a33 100644 --- a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -1,10 +1,8 @@ import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface McpStatusReportOptions { - readonly colors: ColorPalette; readonly servers: readonly McpServerInfo[]; } @@ -34,18 +32,17 @@ const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ function statusPainter( status: McpServerInfo['status'], - colors: ColorPalette, ): (text: string) => string { switch (status) { case 'connected': - return chalk.hex(colors.success); + return (text) => currentTheme.fg('success', text); case 'failed': - return chalk.hex(colors.error); + return (text) => currentTheme.fg('error', text); case 'needs-auth': case 'pending': - return chalk.hex(colors.warning); + return (text) => currentTheme.fg('warning', text); case 'disabled': - return chalk.hex(colors.textDim); + return (text) => currentTheme.fg('textDim', text); } } @@ -97,11 +94,10 @@ function buildSummary(servers: readonly McpServerInfo[]): string { export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { const servers = sortedServers(options.servers); - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const muted = chalk.hex(colors.textDim); - const value = chalk.hex(colors.text); - const error = chalk.hex(colors.error); + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const error = (text: string) => currentTheme.fg('error', text); const lines: string[] = [accent('Servers')]; @@ -129,7 +125,6 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri for (const server of servers) { const status = statusPainter( server.status, - colors, )(STATUS_LABEL[server.status].padEnd(statusWidth)); lines.push( ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index 2158aecd0..4654ee82d 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -1,7 +1,6 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; import { CURATED_BADGE, OFFICIAL_BADGE, @@ -12,16 +11,15 @@ import { } from '../../utils/plugin-source-label'; export interface PluginsListPanelInput { - readonly colors: ColorPalette; readonly plugins: readonly PluginSummary[]; } export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] { - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const primary = chalk.hex(input.colors.primary); - const warning = chalk.hex(input.colors.warning); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const warning = (text: string) => currentTheme.fg('warning', text); if (input.plugins.length === 0) { return [ muted('No plugins installed.'), @@ -56,18 +54,17 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st export interface PluginsInfoPanelInput { - readonly colors: ColorPalette; readonly info: PluginInfo; } export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] { const { info } = input; - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const warning = chalk.hex(input.colors.warning); - const error = chalk.hex(input.colors.error); - const primary = chalk.hex(input.colors.primary); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const warning = (text: string) => currentTheme.fg('warning', text); + const error = (text: string) => currentTheme.fg('error', text); + const primary = (text: string) => currentTheme.fg('primary', text); const status = info.enabled ? success('enabled') : muted('disabled'); const trustLine = (() => { const label = pluginTrustLabel(info); @@ -81,7 +78,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st })(); const lines: string[] = [ `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), - `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`, + `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state)}`, trustLine, `${muted('Source:')} ${value(info.source)}`, `${muted('Root:')} ${value(info.root)}`, @@ -164,7 +161,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st return lines; } -function stateText(state: PluginInfo['state'], colors: ColorPalette): string { - if (state === 'ok') return chalk.hex(colors.success)(state); - return chalk.hex(colors.error)(state); +function stateText(state: PluginInfo['state']): string { + if (state === 'ok') return currentTheme.fg('success', state); + return currentTheme.fg('error', state); } diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 1d48f3c37..3910be1ab 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -22,10 +22,9 @@ import type { TUI } from '@earendil-works/pi-tui'; import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; @@ -42,11 +41,9 @@ export class ReadGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -134,47 +131,55 @@ export class ReadGroupComponent extends Container { } private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); if (pending > 0) { - const bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Reading ${String(total)} files…`); + const bullet = currentTheme.fg('text', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); return `${bullet}${label}`; } // All reads have finished, either successfully or with failures. if (failed === total) { - const bullet = chalk.hex(colors.error)('✗ '); - const label = chalk.hex(colors.error).bold(`Read ${String(total)} files`); - return `${bullet}${label}${chalk.hex(colors.error)(' · failed')}`; + const bullet = currentTheme.fg('error', '✗ '); + const label = currentTheme.boldFg('error', `Read ${String(total)} files`); + return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; } - const bullet = chalk.hex(colors.success)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Read ${String(total)} files`); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? chalk.hex(colors.error)(` · ${String(failed)} failed`) : ''; + const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; return `${bullet}${label}${linesPart}${failPart}`; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); const branch = isLast ? '└─' : '├─'; const path = snap.filePath ?? ''; - const pathPart = chalk.hex(colors.text)(path); + const pathPart = currentTheme.fg('text', path); let tail: string; if (snap.phase === 'pending') { tail = dim(' · reading…'); } else if (snap.phase === 'failed') { - tail = chalk.hex(colors.error)(' · failed'); + tail = currentTheme.fg('error', ' · failed'); } else { tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); } return ` ${branch} ${pathPart}${tail}`; } + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + /** Releases throttle timers so destroyed components cannot refresh later. */ dispose(): void { if (this.throttleTimer !== null) { diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index bb5ecd292..06023d55d 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,8 +1,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; @@ -12,7 +11,6 @@ import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; readonly result?: ToolResultBlockData; - readonly colors: ColorPalette; readonly expanded?: boolean; readonly showCommand?: boolean; /** @@ -35,7 +33,6 @@ export class ShellExecutionComponent extends Container { if (options.result !== undefined) { this.addResultPreview( options.result, - options.colors, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, ); @@ -48,13 +45,12 @@ export class ShellExecutionComponent extends Container { const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(chalk.dim(prefix + line), 2, 0)); + this.addChild(new Text(currentTheme.dim(prefix + line), 2, 0)); } } private addResultPreview( result: ToolResultBlockData, - colors: ColorPalette, expanded: boolean, previewLines: number, ): void { @@ -63,7 +59,6 @@ export class ShellExecutionComponent extends Container { new TruncatedOutputComponent(result.output, { expanded, isError: result.is_error ?? false, - colors, maxLines: previewLines, }), ); @@ -78,7 +73,6 @@ export const shellExecutionResultRenderer: ResultRenderer = ( new ShellExecutionComponent({ command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, - colors: ctx.colors, expanded: ctx.expanded, // Header truncates long bash commands to 60 chars. When the user expands // the card with ctrl+o, reveal the full command (no line cap) so they diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts index 4526328c2..907e91e9d 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -13,30 +13,52 @@ */ import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; const ARGS_PREVIEW_MAX = 200; export class SkillActivationComponent extends Container { + private headText: Text; + private previewText?: Text; + private name: string; + private args?: string; + constructor( name: string, args: string | undefined, - colors: ColorPalette, readonly trigger?: SkillActivationTrigger, ) { super(); + this.name = name; + this.args = args; this.addChild(new Spacer(1)); const head = - chalk.hex(colors.primary).bold('▶ Activated skill: ') + chalk.hex(colors.roleUser).bold(name); - this.addChild(new Text(head, 0, 0)); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); const trimmed = args?.trim() ?? ''; if (trimmed.length > 0) { const preview = trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; - this.addChild(new Text(' ' + chalk.hex(colors.textDim)(preview), 0, 0)); + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); } } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index be2292358..7377a3f52 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,23 +1,57 @@ import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; export class StatusMessageComponent extends Container { - constructor(content: string, colors: ColorPalette, color?: string) { + private textComponent: Text; + private content: string; + private color?: ColorToken; + + constructor(content: string, color?: ColorToken) { super(); - const text = color === undefined ? chalk.hex(colors.textDim)(content) : chalk.hex(color)(content); - this.addChild(new Text(` ${text}`, 0, 0)); + this.content = content; + this.color = color; + const text = color === undefined + ? currentTheme.fg('textDim', content) + : currentTheme.fg(color, content); + this.textComponent = new Text(` ${text}`, 0, 0); + this.addChild(this.textComponent); + } + + override invalidate(): void { + const text = this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + this.textComponent.setText(` ${text}`); + super.invalidate(); } } export class NoticeMessageComponent extends Container { - constructor(title: string, detail: string | undefined, colors: ColorPalette) { + private titleText: Text; + private detailText?: Text; + private title: string; + private detail?: string; + + constructor(title: string, detail: string | undefined) { super(); + this.title = title; + this.detail = detail; this.addChild(new Spacer(1)); - this.addChild(new Text(` ${chalk.hex(colors.textStrong)(title)}`, 0, 0)); + this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); + this.addChild(this.titleText); if (detail !== undefined && detail.length > 0) { - this.addChild(new Text(` ${chalk.hex(colors.textDim)(detail)}`, 0, 0)); + this.detailText = new Text(` ${currentTheme.fg('textDim', detail)}`, 0, 0); + this.addChild(this.detailText); } } + + override invalidate(): void { + this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); + if (this.detailText !== undefined && this.detail !== undefined) { + this.detailText.setText(` ${currentTheme.fg('textDim', this.detail)}`); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index c60e2f546..9007b8f97 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -6,10 +6,9 @@ */ import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { PRODUCT_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { formatTokenCount, ratioSeverity, @@ -26,7 +25,6 @@ interface FieldRow { } export interface StatusReportOptions { - readonly colors: ColorPalette; readonly version: string; readonly model: string; readonly workDir: string; @@ -89,13 +87,12 @@ function contextValues(options: StatusReportOptions): { } export function buildStatusReportLines(options: StatusReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; @@ -125,7 +122,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); - const barColoured = chalk.hex(severityHex(ratioSeverity(safeRatio)))(bar); + const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), @@ -135,7 +132,6 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index f24cac6b6..b54c378af 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -1,23 +1,19 @@ import type { Component } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; export class SwarmModeMarkerComponent implements Component { - constructor( - private readonly state: SwarmModeMarkerState, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly state: SwarmModeMarkerState) {} invalidate(): void {} render(_width: number): string[] { - const color = this.state === 'inactive' ? this.colors.textDim : this.colors.success; - const marker = chalk.hex(color).bold(STATUS_BULLET); - const label = chalk.hex(color).bold(swarmMarkerLabel(this.state)); + const token = this.state === 'inactive' ? 'textDim' : 'success'; + const marker = currentTheme.boldFg(token, STATUS_BULLET); + const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state)); return ['', marker + label]; } } diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 0fee3669a..fe6486374 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -7,7 +7,6 @@ import type { Component, TUI } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { BRAILLE_SPINNER_FRAMES, @@ -16,13 +15,12 @@ import { THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ThinkingRenderMode = 'live' | 'finalized'; export class ThinkingComponent implements Component { private text: string; - private color: string; private showMarker: boolean; private mode: ThinkingRenderMode; private expanded = false; @@ -37,13 +35,11 @@ export class ThinkingComponent implements Component { constructor( text: string, - colors: ColorPalette, showMarker: boolean = true, mode: ThinkingRenderMode = 'finalized', ui?: TUI, ) { this.text = text; - this.color = colors.roleThinking; this.showMarker = showMarker; this.mode = mode; this.ui = ui; @@ -53,7 +49,9 @@ export class ThinkingComponent implements Component { } } - invalidate(): void {} + invalidate(): void { + this.textComponent.setText(this.styled(this.text)); + } setText(text: string): void { if (this.text === text) return; @@ -62,7 +60,7 @@ export class ThinkingComponent implements Component { } private styled(text: string): string { - return chalk.hex(this.color).italic(text); + return currentTheme.italicFg('textDim', text); } finalize(): void { @@ -88,19 +86,20 @@ export class ThinkingComponent implements Component { contentLines.length > THINKING_PREVIEW_LINES ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) : contentLines; - const spinner = chalk.hex(this.color)( + const spinner = currentTheme.fg( + 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); return [ '', - spinner + chalk.hex(this.color)('thinking...'), + spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; } const rendered: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? chalk.hex(this.color)(STATUS_BULLET) : MESSAGE_INDENT; + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; rendered.push(p + contentLines[i]); } @@ -112,7 +111,7 @@ export class ThinkingComponent implements Component { const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); const remaining = contentLines.length - THINKING_PREVIEW_LINES; truncated.push( - MESSAGE_INDENT + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), + MESSAGE_INDENT + currentTheme.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), ); return truncated; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index c0c8e8999..3c6bfe080 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -6,9 +6,7 @@ import { isAbsolute, relative, sep } from 'node:path'; import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, MarkdownTheme, TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import type { Component, TUI } from '@earendil-works/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { @@ -21,7 +19,8 @@ import { STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; @@ -490,10 +489,9 @@ class PrefixedWrappedLine implements Component { export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; + private readonly markdownTheme = createMarkdownTheme(); private result: ToolResultBlockData | undefined; - private colors: ColorPalette; private ui: TUI | undefined; - private markdownTheme: MarkdownTheme | undefined; private planPath: string | undefined; /** * Fallback plan body used when the LLM uses plan-file mode and @@ -572,17 +570,13 @@ export class ToolCallComponent extends Container { constructor( toolCall: ToolCallBlockData, result: ToolResultBlockData | undefined, - colors: ColorPalette, ui?: TUI, - markdownTheme?: MarkdownTheme, private readonly workspaceDir?: string, ) { super(); this.toolCall = toolCall; this.result = result; - this.colors = colors; this.ui = ui; - this.markdownTheme = markdownTheme; this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -597,6 +591,12 @@ export class ToolCallComponent extends Container { this.syncSubagentElapsedTimer(); } + override invalidate(): void { + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + super.invalidate(); + } + setExpanded(expanded: boolean): void { if (this.expanded === expanded) return; this.expanded = expanded; @@ -1208,24 +1208,24 @@ export class ToolCallComponent extends Container { } private buildHeader(): string { - const { toolCall, result, colors } = this; + const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; const isTruncated = toolCall.truncated === true && !isFinished; let bullet: string; if (isFinished) { - bullet = isError ? chalk.hex(colors.error)('✗ ') : chalk.hex(colors.success)(STATUS_BULLET); + bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { - bullet = chalk.hex(colors.error)('✗ '); + bullet = currentTheme.fg('error', '✗ '); } else { // Solid bullet for in-flight tools — the previous marker ↔ blank // toggle caused visible flicker on every re-render. - bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + bullet = currentTheme.fg('text', STATUS_BULLET); } if (toolCall.name === 'ExitPlanMode') { - const label = chalk.hex(colors.primary).bold('Current plan'); + const label = currentTheme.boldFg('primary', 'Current plan'); if (!isFinished || result === undefined || result.is_error === true) { return label; } @@ -1235,7 +1235,7 @@ export class ToolCallComponent extends Container { outcome.chosen !== undefined && outcome.chosen.length > 0 ? `Approved: ${outcome.chosen}` : 'Approved'; - return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; + return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; } return label; } @@ -1251,14 +1251,13 @@ export class ToolCallComponent extends Container { : isBackgroundAsk ? 'Starting background question' : 'Waiting for your input'; - const tone = isError ? chalk.hex(colors.error) : chalk.hex(colors.primary); - return `${bullet}${tone.bold(label)}`; + const tone = isError ? 'error' : 'primary'; + return `${bullet}${currentTheme.boldFg(tone, label)}`; } const goalHeader = buildGoalToolHeader({ toolCall, result, - colors, bullet, chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', }); @@ -1272,13 +1271,13 @@ export class ToolCallComponent extends Container { const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated - ? chalk.hex(colors.error)(verb) + ? currentTheme.fg('error', verb) : verb; const toolLabel = decoded !== null - ? `${chalk.hex(colors.primary).bold(decoded.toolName)}${chalk.dim(` · MCP/${decoded.serverName}`)}` - : chalk.hex(colors.primary).bold(toolCall.name); - const argStr = keyArg ? chalk.dim(` (${keyArg})`) : ''; + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); + const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; @@ -1289,8 +1288,8 @@ export class ToolCallComponent extends Container { if (provider === undefined) return ''; const text = provider(this.toolCall, result); if (text.length === 0) return ''; - const tone = result.is_error ? chalk.hex(this.colors.error) : chalk.dim; - return tone(` · ${text}`); + if (result.is_error) return currentTheme.fg('error', ` · ${text}`); + return currentTheme.dim(` · ${text}`); } private rebuildContent(): void { @@ -1334,10 +1333,10 @@ export class ToolCallComponent extends Container { PROGRESS_URL_RE.lastIndex = 0; const styled = PROGRESS_URL_RE.test(raw) ? raw.replace(PROGRESS_URL_RE, (url) => { - const visible = chalk.hex(this.colors.warning).underline(url); + const visible = currentTheme.underlineFg('warning', url); return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; }) - : chalk.dim(raw); + : currentTheme.dim(raw); PROGRESS_URL_RE.lastIndex = 0; this.addChild(new Text(styled, 2, 0)); } @@ -1360,19 +1359,18 @@ export class ToolCallComponent extends Container { return; } - const dim = chalk.dim; const phaseChip = this.formatPhaseChip(); const headerLabel = this.subagentAgentName !== undefined ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` : `subagent (${this.formatAgentId()})`; - this.addChild(new Text(` ${dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); if (this.hiddenSubCallCount > 0) { const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; this.addChild( new Text( - dim.italic(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`), + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`)), 0, 0, ), @@ -1381,26 +1379,26 @@ export class ToolCallComponent extends Container { for (const sub of this.finishedSubCalls) { const mark = sub.isError - ? chalk.hex(this.colors.error)('✗') - : chalk.hex(this.colors.success)('•'); + ? currentTheme.fg('error', '✗') + : currentTheme.fg('success', '•'); const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(sub.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', sub.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); } for (const [id, call] of this.ongoingSubCalls) { const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(call.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', call.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; void id; - this.addChild(new Text(` ${dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); } if (this.subagentText.length > 0) { const tailLines = this.subagentText.split('\n').slice(-3); for (const line of tailLines) { - this.addChild(new Text(` ${dim(line)}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(line)}`, 0, 0)); } } @@ -1408,7 +1406,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); for (const line of summaryLines) { - this.addChild(new Text(` ${dim('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('└')} ${line}`, 0, 0)); } } @@ -1416,7 +1414,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { const errLines = this.subagentError.split('\n'); for (const line of errLines) { - this.addChild(new Text(` ${chalk.hex(this.colors.error)('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.fg('error', '└')} ${line}`, 0, 0)); } } } @@ -1432,7 +1430,6 @@ export class ToolCallComponent extends Container { */ private formatPhaseChip(): string { if (this.subagentPhase === undefined) return ''; - const dim = chalk.dim; const parts: string[] = []; switch (this.subagentPhase) { case 'queued': @@ -1445,7 +1442,7 @@ export class ToolCallComponent extends Container { parts.push('↻ running'); break; case 'done': { - parts.push(chalk.hex(this.colors.success)('✓ done')); + parts.push(currentTheme.fg('success', '✓ done')); const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); const tokens = @@ -1455,13 +1452,13 @@ export class ToolCallComponent extends Container { break; } case 'failed': - parts.push(chalk.hex(this.colors.error)('✗ failed')); + parts.push(currentTheme.fg('error', '✗ failed')); break; case 'backgrounded': parts.push('◐ backgrounded'); break; } - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; } private formatAgentId(): string { @@ -1499,40 +1496,39 @@ export class ToolCallComponent extends Container { const isFailed = phase === 'failed'; const isDone = phase === 'done'; const bullet = isFailed - ? chalk.hex(this.colors.error)('✗ ') + ? currentTheme.fg('error', '✗ ') : isDone - ? chalk.hex(this.colors.success)(STATUS_BULLET) - : chalk.hex(this.colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); const labelText = formatSubagentLabel(this.subagentAgentName); - const label = chalk.hex(this.colors.primary).bold(labelText); + const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); const description = str(this.toolCall.args['description']); const descriptionPlain = description.length > 0 ? ` (${description})` : ''; - const descriptionText = descriptionPlain.length > 0 ? chalk.dim(descriptionPlain) : ''; + const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - const success = chalk.hex(this.colors.success); - return `${bullet}${success.bold(labelText)} ${success(`Completed${descriptionPlain}${statsText}`)}`; + return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } - const stats = chalk.dim(statsText); + const stats = currentTheme.dim(statsText); return `${bullet}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { switch (phase) { case 'done': - return chalk.hex(this.colors.success)('Completed'); + return currentTheme.fg('success', 'Completed'); case 'failed': - return chalk.hex(this.colors.error)('Failed'); + return currentTheme.fg('error', 'Failed'); case 'running': - return chalk.hex(this.colors.primary)('Running'); + return currentTheme.fg('primary', 'Running'); case 'backgrounded': return 'Backgrounded'; case 'queued': - return chalk.hex(this.colors.primary)('Queued'); + return currentTheme.fg('primary', 'Queued'); case 'spawning': case undefined: - return chalk.hex(this.colors.primary)('Starting'); + return currentTheme.fg('primary', 'Starting'); } } @@ -1562,10 +1558,10 @@ export class ToolCallComponent extends Container { for (const activity of this.getRecentSubToolActivities()) { const mark = activity.phase === 'failed' - ? chalk.hex(this.colors.error)('✗') + ? currentTheme.fg('error', '✗') : activity.phase === 'done' - ? chalk.hex(this.colors.success)('•') - : chalk.hex(this.colors.text)('•'); + ? currentTheme.fg('success', '•') + : currentTheme.fg('text', '•'); const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); this.addSubToolOutputPreview(activity); @@ -1576,9 +1572,9 @@ export class ToolCallComponent extends Container { if (errorLine !== undefined) { this.addChild( new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.error)('└')} `, + ` ${currentTheme.fg('error', '└')} `, ' ', - chalk.hex(this.colors.error)(errorLine), + currentTheme.fg('error', errorLine), ), ); } @@ -1594,9 +1590,9 @@ export class ToolCallComponent extends Container { // the main agent's live thinking instead of growing without bound. this.addChild( new PrefixedWrappedLine( - ` ${chalk.dim('◌')} `, + ` ${currentTheme.dim('◌')} `, ' ', - chalk.dim(this.subagentThinkingText.trimEnd()), + currentTheme.dim(this.subagentThinkingText.trimEnd()), THINKING_PREVIEW_LINES, ), ); @@ -1604,9 +1600,9 @@ export class ToolCallComponent extends Container { if (outputLine !== undefined) { this.addChild( new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.text)('└')} `, + ` ${currentTheme.fg('text', '└')} `, ' ', - chalk.hex(this.colors.text)(outputLine), + currentTheme.fg('text', outputLine), ), ); } @@ -1627,7 +1623,6 @@ export class ToolCallComponent extends Container { expanded: false, expandHint: false, isError: activity.phase === 'failed', - colors: this.colors, maxLines: RESULT_PREVIEW_LINES, indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, }), @@ -1642,8 +1637,8 @@ export class ToolCallComponent extends Container { private formatSubToolActivity(verb: string, activity: SubToolActivity): string { const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(activity.name); - const argCol = keyArg ? chalk.dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', activity.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; return `${verb} ${nameCol}${argCol}`; } @@ -1656,7 +1651,7 @@ export class ToolCallComponent extends Container { if (this.result === undefined && this.toolCall.truncated === true) { this.addChild( new Text( - chalk.dim('Tool call arguments truncated by max_tokens — call never executed.'), + currentTheme.dim('Tool call arguments truncated by max_tokens — call never executed.'), 2, 0, ), @@ -1682,13 +1677,13 @@ export class ToolCallComponent extends Container { const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { - const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } if (writeShouldCap && remaining > 0) { this.addChild( new Text( - chalk.dim( + currentTheme.dim( `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, ), 2, @@ -1701,7 +1696,7 @@ export class ToolCallComponent extends Container { const newStr = str(this.toolCall.args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return; const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); - const lines = renderDiffLinesClustered(oldStr, newStr, filePath, this.colors, { + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); @@ -1743,7 +1738,7 @@ export class ToolCallComponent extends Container { allLines.length > maxLines ? allLines.length - maxLines + i : i; - const lineNum = chalk.dim(String(originalLineNumber + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } return; @@ -1761,7 +1756,7 @@ export class ToolCallComponent extends Container { const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( elapsedSeconds, )} elapsed`; - this.addChild(new Text(chalk.dim(progress), 2, 0)); + this.addChild(new Text(currentTheme.dim(progress), 2, 0)); return; } if (name === 'Bash') { @@ -1770,7 +1765,6 @@ export class ToolCallComponent extends Container { this.addChild( new ShellExecutionComponent({ command: cmd, - colors: this.colors, showCommand: true, commandPreviewLines: COMMAND_PREVIEW_LINES, }), @@ -1783,20 +1777,15 @@ export class ToolCallComponent extends Container { private buildPlanPreview(): void { // Priority: inline `args.plan`, approved plan parsed from result, then // asynchronously injected currentPlan used while approval is in flight. - // Once a plan is found, PlanBoxComponent renders it. Without markdownTheme - // (unit tests), fall back to indented dim text so it remains visible. + // Once a plan is found, PlanBoxComponent renders it. const plan = this.resolvePlanForPreview(); if (plan.length === 0) return; const path = this.resolvePlanPath(); - if (this.markdownTheme !== undefined) { - this.addChild( - new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { - status: this.resolvePlanBoxStatus(), - }), - ); - } else { - this.addChild(new Text(chalk.dim(plan), 2, 0)); - } + this.addChild( + new PlanBoxComponent(plan, this.markdownTheme, currentTheme.color('success'), path, { + status: this.resolvePlanBoxStatus(), + }), + ); } private resolvePlanForPreview(): string { @@ -1825,7 +1814,7 @@ export class ToolCallComponent extends Container { if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; const outcome = interpretExitPlanModeOutcome(result.output); if (outcome.kind !== 'rejected') return undefined; - return { label: 'Rejected', colorHex: this.colors.error }; + return { label: 'Rejected', colorHex: currentTheme.color('error') }; } private buildContent(): void { @@ -1858,7 +1847,7 @@ export class ToolCallComponent extends Container { if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { const trimmed = outcome.feedback.trim(); if (trimmed.length > 0) { - const labelTone = chalk.hex(this.colors.warning).bold; + const labelTone = (text: string) => currentTheme.boldFg('warning', text); this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); for (const line of trimmed.split('\n')) { this.addChild(new Text(line, 4, 0)); @@ -1891,7 +1880,6 @@ export class ToolCallComponent extends Container { const renderer = pickResultRenderer(this.toolCall.name); const components = renderer(this.toolCall, result, { expanded: this.expanded, - colors: this.colors, }); for (const component of components) { this.addChild(component); @@ -1900,23 +1888,23 @@ export class ToolCallComponent extends Container { private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { const summary = agentSwarmResultSummaryFromOutput(result.output); - const dim = chalk.hex(this.colors.textDim); + const dim = (s: string): string => currentTheme.fg('textDim', s); const segments: string[] = []; if (summary.completed > 0) { - segments.push(chalk.hex(this.colors.success)( - `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`, - )); + segments.push( + currentTheme.fg('success', `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`), + ); } if (summary.failed > 0) { - segments.push(chalk.hex(this.colors.error)( - `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`, - )); + segments.push( + currentTheme.fg('error', `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`), + ); } if (summary.aborted > 0) { - segments.push(chalk.hex(this.colors.warning)( - `${ABORTED_MARK} ${String(summary.aborted)} aborted`, - )); + segments.push( + currentTheme.fg('warning', `${ABORTED_MARK} ${String(summary.aborted)} aborted`), + ); } if (segments.length > 0) { @@ -1925,17 +1913,13 @@ export class ToolCallComponent extends Container { } const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); - const color = isAborted - ? this.colors.warning - : result.is_error === true - ? this.colors.error - : this.colors.success; + const colorToken = isAborted ? 'warning' : result.is_error === true ? 'error' : 'success'; const label = isAborted ? `${ABORTED_MARK} Aborted.` : result.is_error === true ? `${FAILURE_MARK.trimEnd()} Failed.` : `${SUCCESS_MARK.trimEnd()} Completed.`; - this.addChild(new Text(`${dim('Agent swarm: ')}${chalk.hex(color)(label)}`, 2, 0)); + this.addChild(new Text(`${dim('Agent swarm: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); } /** @@ -1952,9 +1936,7 @@ export class ToolCallComponent extends Container { } if (typeof parsed !== 'object' || parsed === null) return false; - const colors = this.colors; - const dim = chalk.dim; - const accent = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); const answers = (parsed as { answers?: unknown }).answers; const note = (parsed as { note?: unknown }).note; @@ -1965,13 +1947,13 @@ export class ToolCallComponent extends Container { if (!hasAnswers) { const noteText = typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; - this.addChild(new Text(dim(` ${noteText}`), 0, 0)); + this.addChild(new Text(currentTheme.dim(` ${noteText}`), 0, 0)); return true; } for (const [question, answer] of Object.entries(answers as Record)) { const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); - this.addChild(new Text(` ${dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); } return true; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index bcf1e72bf..04f20dc35 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,8 +1,7 @@ import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { formatTokenCount } from '#/utils/usage/usage-format'; @@ -50,24 +49,23 @@ export const goalSummary: ResultRenderer = (toolCall, result, ctx) => { export function buildGoalToolHeader(options: { readonly toolCall: ToolCallBlockData; readonly result: ToolResultBlockData | undefined; - readonly colors: ColorPalette; readonly bullet: string; readonly chip: string; }): string | undefined { - const { toolCall, result, colors, bullet, chip } = options; + const { toolCall, result, bullet, chip } = options; if (!isGoalToolName(toolCall.name)) return undefined; - const tone = result?.is_error === true ? colors.error : colors.primary; - const label = chalk.hex(tone).bold(goalToolLabel(toolCall.name, result, toolCall.args)); + const tone = result?.is_error === true ? 'error' : 'primary'; + const label = currentTheme.boldFg(tone, goalToolLabel(toolCall.name, result, toolCall.args)); const marker = result !== undefined && result.is_error !== true - ? chalk.hex(colors.primary)(STATUS_BULLET) + ? currentTheme.fg('primary', STATUS_BULLET) : bullet; const arg = toolCall.name === 'UpdateGoal' ? undefined : formatGoalToolArgument(toolCall.name, toolCall.args); - const argText = arg === undefined ? '' : chalk.hex(colors.textDim)(` (${arg})`); + const argText = arg === undefined ? '' : currentTheme.dimFg('textDim', ` (${arg})`); return `${marker}${label}${argText}${chip}`; } @@ -95,13 +93,13 @@ export function goalStatusChip(output: string): string { function renderGoalSnapshot( toolCall: ToolCallBlockData, result: ToolResultBlockData, - ctx: Parameters[2], + _ctx: Parameters[2], ) { const goal = parseGoalToolOutput(result.output); - if (goal === undefined) return renderTruncated(toolCall, result, ctx); + if (goal === undefined) return renderTruncated(toolCall, result, _ctx); - const muted = chalk.hex(ctx.colors.textDim); - const value = chalk.hex(ctx.colors.text); + const muted = (s: string) => currentTheme.dimFg('textDim', s); + const value = (s: string) => currentTheme.fg('text', s); if (goal === null) return [new Text(muted(' No current goal.'), 0, 0)]; const lines = [ diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 54706fc99..1933bfd50 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,8 +1,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -26,7 +25,7 @@ export function trimTrailingEmptyLines(lines: string[]): string[] { * JSON blobs) that would otherwise wrap to dozens of visual rows. */ export class TruncatedOutputComponent implements Component { - private readonly textComponent: Text; + private textComponent: Text; private readonly expanded: boolean; private readonly maxLines: number; private readonly indent: number; @@ -37,7 +36,6 @@ export class TruncatedOutputComponent implements Component { options: { expanded: boolean; isError: boolean | undefined; - colors: ColorPalette; maxLines?: number; indent?: number; // When false, the truncation footer omits the "ctrl+o to expand" promise @@ -49,12 +47,16 @@ export class TruncatedOutputComponent implements Component { this.maxLines = options.maxLines ?? PREVIEW_LINES; this.indent = options.indent ?? DEFAULT_INDENT; this.expandHint = options.expandHint ?? true; - const tint = options.isError ? chalk.hex(options.colors.error) : chalk.dim; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); - this.textComponent = new Text(tint(cleaned), this.indent, 0); + this.textComponent = new Text( + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + this.indent, + 0, + ); } invalidate(): void { + // Text component caches wrapped lines; invalidate on terminal resize. this.textComponent.invalidate(); } @@ -70,7 +72,7 @@ export class TruncatedOutputComponent implements Component { const hint = this.expandHint ? `... (${String(remaining)} more lines, ctrl+o to expand)` : `... (${String(remaining)} more lines)`; - return [...shown, ' '.repeat(this.indent) + chalk.dim(hint)]; + return [...shown, ' '.repeat(this.indent) + currentTheme.dim(hint)]; } } @@ -80,7 +82,6 @@ export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, isError: result.is_error ?? false, - colors: ctx.colors, }), ]; }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 462b53a44..94161d1a8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,12 +1,10 @@ import type { Component } from '@earendil-works/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; export interface RendererContext { readonly expanded: boolean; - readonly colors: ColorPalette; } export type ResultRenderer = ( diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 0e4401a11..71579e1c5 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -7,7 +7,6 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { formatTokenCount, @@ -15,7 +14,8 @@ import { renderProgressBar, safeUsageRatio, } from '#/utils/usage/usage-format'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; @@ -36,7 +36,6 @@ export interface ManagedUsageReport { } export interface UsageReportOptions { - readonly colors: ColorPalette; readonly sessionUsage?: SessionUsage; readonly sessionUsageError?: string; readonly contextUsage: number; @@ -47,7 +46,6 @@ export interface UsageReportOptions { } export interface ManagedUsageReportLineOptions { - readonly colors: ColorPalette; readonly managedUsage?: ManagedUsageReport; readonly managedUsageError?: string; } @@ -108,7 +106,6 @@ function buildManagedUsageSection( value: Colorize, muted: Colorize, errorStyle: Colorize, - severityHex: (sev: 'ok' | 'warn' | 'danger') => string, ): string[] { if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; if (usage === undefined) return []; @@ -124,12 +121,14 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); + const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); const label = row.label.padEnd(labelWidth, ' '); const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); @@ -138,13 +137,10 @@ function buildManagedUsageSection( } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); return buildManagedUsageSection( options.managedUsage, @@ -153,18 +149,16 @@ export function buildManagedUsageReportLines(options: ManagedUsageReportLineOpti value, muted, errorStyle, - severityHex, ); } export function buildUsageReportLines(options: UsageReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -181,7 +175,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); const pct = `${(ratio * 100).toFixed(1)}%`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratio)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); lines.push( @@ -195,7 +189,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -208,16 +201,25 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } export class UsagePanelComponent implements Component { - constructor( - private readonly lines: readonly string[], - private readonly borderHex: string, - private readonly title: string = ' Usage ', - ) {} + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; - invalidate(): void {} + constructor( + private readonly buildLines: () => readonly string[], + private readonly borderToken: ColorToken, + private readonly title: string = ' Usage ', + ) { + this.lines = buildLines(); + } + + invalidate(): void { + // Report bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines (the data itself is captured). + this.lines = this.buildLines(); + } render(width: number): string[] { - const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); const indent = ' '.repeat(LEFT_MARGIN); const availableInterior = Math.max( diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 8617598a0..dd99d3c26 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -4,35 +4,31 @@ import type { Component } from '@earendil-works/pi-tui'; import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; export class UserMessageComponent implements Component { - private color: string; - private textComponent: Text; + private text: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, colors: ColorPalette, images?: ImageAttachment[]) { - this.color = colors.roleUser; - this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0); + constructor(text: string, images?: ImageAttachment[]) { + this.text = text; this.spacerComponent = new Spacer(1); - this.imageThumbnails = images?.map((img) => new ImageThumbnail(img, colors)) ?? []; + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } invalidate(): void { - this.textComponent.invalidate(); for (const img of this.imageThumbnails) { img.invalidate?.(); } } render(width: number): string[] { - const bullet = chalk.hex(this.color).bold(USER_MESSAGE_BULLET); + const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET); const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, width - bulletWidth); @@ -43,8 +39,9 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text - const textLines = this.textComponent.render(contentWidth); + // Text — re-dye on every render so theme switches are reflected + const coloredText = currentTheme.boldFg('roleUser', this.text); + const textLines = new Text(coloredText, 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); lines.push(prefix + textLines[i]); diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 990f6b59a..f86f93c93 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -8,7 +8,7 @@ import { import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '../../theme'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -28,7 +28,6 @@ interface BtwBodyRender { } export interface BtwPanelOptions { - readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; readonly canUseScrollKeys: () => boolean; readonly onPrompt: (prompt: string) => void; @@ -119,14 +118,14 @@ export class BtwPanelComponent implements Component { } private renderTopBorder(width: number, truncated: boolean): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const hint = truncated && this.options.canUseScrollKeys() ? 'Esc close · ↑↓ scroll ' : 'Esc close '; const title = - chalk.hex(this.options.colors.accent).bold(' BTW ') + + chalk.hex(currentTheme.palette.accent).bold(' BTW ') + paint('─ ') + - chalk.hex(this.options.colors.textMuted)(hint); + chalk.hex(currentTheme.palette.textMuted)(hint); const innerWidth = Math.max(1, width - 2); const clippedTitle = visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; @@ -141,7 +140,7 @@ export class BtwPanelComponent implements Component { lines.push(...this.renderTurn(turn, width)); } if (this.turns.length === 0) { - lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question...')); } lines.push(...this.renderTransientNotices(width)); return this.fitBodyLines(lines); @@ -150,7 +149,7 @@ export class BtwPanelComponent implements Component { private renderTransientNotices(width: number): string[] { const lines: string[] = []; for (const notice of this.transientNotices) { - lines.push(...new Text(chalk.hex(this.options.colors.textDim)(notice), 0, 0).render(width)); + lines.push(...new Text(chalk.hex(currentTheme.palette.textDim)(notice), 0, 0).render(width)); } return lines; } @@ -191,14 +190,14 @@ export class BtwPanelComponent implements Component { } private renderTurn(turn: BtwTurn, width: number): string[] { - const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + const prompt = chalk.hex(currentTheme.palette.accent)(`Q: ${turn.prompt}`); const lines = [...new Text(prompt, 0, 0).render(width)]; const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); } else if (thinking.length > 0) { - const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( width, ); const visibleThinking = @@ -207,17 +206,17 @@ export class BtwPanelComponent implements Component { : thinkingLines; lines.push(...visibleThinking); } else if (turn.error === undefined) { - lines.push(chalk.hex(this.options.colors.textDim)('Waiting for answer...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); } if (turn.error !== undefined) { - const error = chalk.hex(this.options.colors.error)(turn.error); + const error = chalk.hex(currentTheme.palette.error)(turn.error); lines.push(...new Text(error, 0, 0).render(width)); } return lines; } private renderBodyLine(line: string, width: number): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const contentWidth = Math.max(1, width - 4); const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 17c2886a0..3a255b52d 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,13 +1,11 @@ import { Container, truncateToWidth, visibleWidth } 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'; +import { currentTheme } from '#/tui/theme'; export interface QueuePaneOptions { readonly messages: readonly QueuedMessage[]; - readonly colors: ColorPalette; readonly isCompacting: boolean; readonly isStreaming: boolean; readonly canSteerImmediately: boolean; @@ -17,13 +15,11 @@ const ELLIPSIS = '…'; export class QueuePaneComponent extends Container { private readonly messages: readonly QueuedMessage[]; - private readonly colors: ColorPalette; private readonly hint: string | undefined; constructor(options: QueuePaneOptions) { super(); this.messages = options.messages; - this.colors = options.colors; if (options.messages.length > 0) { this.hint = @@ -36,8 +32,8 @@ export class QueuePaneComponent extends Container { } override render(width: number): string[] { - const accent = chalk.hex(this.colors.accent); - const dim = chalk.hex(this.colors.textDim); + const accent = (text: string) => currentTheme.fg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); const lines: string[] = []; for (const item of this.messages) { diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 54d4a8763..fdcd8714e 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -17,7 +17,7 @@ import { getDataDir } from '#/utils/paths'; export const INVALID_TUI_CONFIG_MESSAGE = 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; -export const TuiThemeSchema = z.enum(['dark', 'light', 'auto']); +export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); @@ -149,7 +149,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. -theme = "${config.theme}" # "auto" | "dark" | "light" +theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 7ebe79118..b045f8701 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -10,6 +10,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; import type { TUIState } from '../tui-state'; const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; @@ -36,8 +37,7 @@ export class BtwPanelController { open(agentId: string, initialPrompt: string): void { let panel: BtwPanelComponent; panel = new BtwPanelComponent({ - colors: this.host.state.theme.colors, - markdownTheme: this.host.state.theme.markdownTheme, + markdownTheme: createMarkdownTheme(), canUseScrollKeys: () => this.host.state.editor.getText().length === 0, terminalRows: () => this.host.state.terminal.rows, onPrompt: (prompt) => { diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index a6d5ed013..09593c927 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import type { Component, Focusable } from '@earendil-works/pi-tui'; import type { AgentStatusUpdatedEvent, @@ -67,6 +66,8 @@ import { selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; @@ -97,7 +98,7 @@ export interface SessionEventHost { patchLivePane(patch: Partial): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; updateActivityPane(): void; track(event: string, props?: Record): void; @@ -397,7 +398,7 @@ export class SessionEventHandler { if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { this.markActiveAgentSwarmsCancelled(); - this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + this.host.showStatus('Interrupted by user', 'error'); return; } this.host.showError( @@ -577,7 +578,7 @@ export class SessionEventHandler { private renderSwarmModeMarker(state: SwarmModeMarkerState): void { this.host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, this.host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); this.host.state.ui.requestRender(); } @@ -628,12 +629,7 @@ export class SessionEventHandler { } else if (change.kind === 'lifecycle') { this.pendingModelBlockedFallback = undefined; } - const marker = buildGoalMarker( - change, - state.theme.colors, - state.toolOutputExpanded, - change.actor, - ); + const marker = buildGoalMarker(change, state.toolOutputExpanded, change.actor); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); @@ -645,12 +641,7 @@ export class SessionEventHandler { if (change === undefined) return; this.pendingModelBlockedFallback = undefined; const { state } = this.host; - const marker = buildGoalMarker( - change, - state.theme.colors, - state.toolOutputExpanded, - 'model', - ); + const marker = buildGoalMarker(change, state.toolOutputExpanded, 'model'); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); @@ -826,11 +817,10 @@ export class SessionEventHandler { } private handleSessionWarning(event: WarningEvent): void { - this.host.showStatus(`Warning: ${event.message}`, this.host.state.theme.colors.warning); + this.host.showStatus(`Warning: ${event.message}`, 'warning'); } private renderMcpServerStatus(server: McpServerStatusSnapshot): void { - const { state } = this.host; const key = mcpServerStatusKey(server); if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; this.renderedMcpServerStatusKeys.set(server.name, key); @@ -838,29 +828,28 @@ export class SessionEventHandler { const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); this.host.setAppState({ mcpServersSummary: summary || null }); - const colors = state.theme.colors; switch (server.status) { case 'connected': { const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, colors.success); + this.finalizeMcpServerStatusRow(server.name, message, 'success'); return; } case 'failed': { const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.error); + this.finalizeMcpServerStatusRow(server.name, message, 'error'); return; } case 'needs-auth': { const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); return; } case 'disabled': this.finalizeMcpServerStatusRow( server.name, `MCP server "${server.name}" disabled`, - colors.textMuted, + 'textMuted', ); return; case 'pending': @@ -877,14 +866,14 @@ export class SessionEventHandler { existing.setLabel(label); return; } - const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); + const tint = (s: string): string => currentTheme.fg('textMuted', s); const spinner = new MoonLoader(state.ui, 'braille', tint, label); state.transcriptContainer.addChild(spinner); this.mcpServerStatusSpinners.set(name, spinner); state.ui.requestRender(); } - private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { const { state } = this.host; const spinner = this.mcpServerStatusSpinners.get(name); if (spinner === undefined) { @@ -892,7 +881,7 @@ export class SessionEventHandler { return; } spinner.stop(); - const status = new StatusMessageComponent(message, state.theme.colors, color); + const status = new StatusMessageComponent(message, color); const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 881489ea7..beb1b4e16 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -553,10 +553,7 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', }; - const component = new AssistantMessageComponent( - state.theme.markdownTheme, - state.theme.colors, - ); + const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); @@ -584,7 +581,6 @@ export class StreamingUIController { this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( fullText, - state.theme.colors, true, 'live', state.ui, @@ -611,9 +607,7 @@ export class StreamingUIController { const tc = new ToolCallComponent( toolCall, undefined, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) tc.setExpanded(true); @@ -657,9 +651,7 @@ export class StreamingUIController { const completed = new ToolCallComponent( matchedCall, result, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) completed.setExpanded(true); @@ -684,7 +676,7 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction); this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); state.ui.requestRender(); @@ -780,7 +772,7 @@ export class StreamingUIController { private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { const { state } = this.host; - const group = new AgentGroupComponent(state.theme.colors, state.ui); + const group = new AgentGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { @@ -837,7 +829,7 @@ export class StreamingUIController { private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { const { state } = this.host; - const group = new ReadGroupComponent(state.theme.colors, state.ui); + const group = new ReadGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 5f774991d..1fb2d71c5 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -523,7 +523,6 @@ export class SubAgentEventHandler { const progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), - colors: this.host.state.theme.colors, availableGridHeight: () => this.agentSwarmGridHeight(), requestRender: () => { this.requestRender(); diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index a7a0c2053..90ed3c99e 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -3,13 +3,13 @@ import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; -import type { ColorPalette } from '../theme'; +import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { readonly tasksBrowser: TasksBrowserState | undefined; - readonly theme: { readonly colors: ColorPalette }; + readonly theme: Theme; readonly terminal: ProcessTerminal; readonly ui: TUI; readonly editor: CustomEditor; @@ -77,7 +77,6 @@ export class TasksBrowserController { tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: state.theme.colors, ...this.buildCallbacks(), }, state.terminal, @@ -167,7 +166,6 @@ export class TasksBrowserController { taskId: viewer.taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, @@ -229,7 +227,6 @@ export class TasksBrowserController { tailOutput: browser.tailOutput, tailLoading: browser.tailLoading, flashMessage: browser.flashMessage, - colors: this.host.state.theme.colors, ...this.buildCallbacks(), }); this.host.state.ui.requestRender(); @@ -343,7 +340,6 @@ export class TasksBrowserController { taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index fe08d17dc..6f638aba0 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -14,7 +14,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; -import type { ColorPalette } from '../theme/colors'; +import { currentTheme } from '../theme'; /** Frame interval for the rainbow flow animation. */ export const DANCE_FRAME_MS = 110; @@ -44,8 +44,8 @@ const LIGHT_RAINBOW = [ '#354CB5', ] as const; -function getDanceRainbowPalette(colors: ColorPalette): readonly [string, ...string[]] { - return colors.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; } /** Paint a string character-by-character through a palette, skipping spaces. */ @@ -110,13 +110,12 @@ export function isRainbowDancing(): boolean { } export function renderDanceWelcomeHeader( - colors: ColorPalette, logo: readonly [string, string], textWidth: number, rightRow1: string, ): string[] { const phase = currentDanceView?.phase ?? 0; - const palette = getDanceRainbowPalette(colors); + const palette = getDanceRainbowPalette(); const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const rightRow0 = truncateToWidth( @@ -131,8 +130,8 @@ export function renderDanceWelcomeHeader( ]; } -export function renderDanceFooterModel(modelLabel: string, colors: ColorPalette): string { - return rainbowText(modelLabel, getDanceRainbowPalette(colors), currentDanceView?.phase ?? 0); +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); } /** @@ -233,7 +232,7 @@ export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlas // The status line dims the whole message, which buried the command in the // hint. Paint just the command in the brand color (bold) so it reads as a // command; chalk nesting resumes the dim run right after it. - const cmd = (text: string): string => chalk.hex(host.state.theme.colors.primary).bold(text); + const cmd = (text: string): string => currentTheme.boldFg('primary', text); const sub = parsed.args.trim().toLowerCase(); if (sub === 'off') { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 10084ea9e..71a43fa3b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -21,7 +21,6 @@ import type { PromptPart, Session, } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; @@ -100,9 +99,8 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import { createKimiTUIThemeBundle } from './theme/bundle'; -import type { ResolvedTheme } from './theme/colors'; -import type { Theme } from './theme/index'; +import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { INITIAL_LIVE_PANE, type AppState, @@ -145,7 +143,6 @@ export interface KimiTUIStartupInput { readonly version: string; readonly workDir: string; readonly startupNotice?: string; - readonly resolvedTheme?: ResolvedTheme; readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; @@ -264,7 +261,6 @@ export class KimiTUI { model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, - resolvedTheme: startupInput.resolvedTheme, }; this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; @@ -456,7 +452,7 @@ export class KimiTUI { for (const f of result.failed) { this.showStatus( `Skipped refreshing ${f.provider}: ${f.reason}`, - this.state.theme.colors.warning, + 'warning', ); } } catch { @@ -479,7 +475,7 @@ export class KimiTUI { } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); @@ -494,7 +490,7 @@ export class KimiTUI { private async showTmuxKeyboardWarningIfNeeded(): Promise { const warning = await detectTmuxKeyboardWarning(); if (warning === undefined || this.aborted) return; - this.showStatus(warning, this.state.theme.colors.warning); + this.showStatus(warning, 'warning'); } private async init(): Promise { @@ -533,7 +529,7 @@ export class KimiTUI { if (resolve(target.workDir) !== resolve(workDir)) { this.state.ui.stop(); process.stderr.write( - `${chalk.hex(this.state.theme.colors.warning)( + `${currentTheme.fg('warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, )}\n\n`, @@ -1195,7 +1191,7 @@ export class KimiTUI { } const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); } @@ -1223,7 +1219,7 @@ export class KimiTUI { this.sessionEventHandler.startSubscription(); const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); } @@ -1272,11 +1268,7 @@ export class KimiTUI { private createTranscriptComponent(entry: TranscriptEntry): Component | null { if (entry.compactionData !== undefined) { const data = entry.compactionData; - const block = new CompactionComponent( - this.state.theme.colors, - this.state.ui, - data.instruction, - ); + const block = new CompactionComponent(this.state.ui, data.instruction); block.markDone(data.tokensBefore, data.tokensAfter); return block; } @@ -1286,46 +1278,37 @@ export class KimiTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, this.state.theme.colors, images); + return new UserMessageComponent(entry.content, images); } case 'skill_activation': return new SkillActivationComponent( entry.skillName ?? entry.content, entry.skillArgs, - this.state.theme.colors, entry.skillTrigger, ); case 'cron': - return new CronMessageComponent( - entry.content, - entry.cronData ?? {}, - this.state.theme.colors, - ); + return new CronMessageComponent(entry.content, entry.cronData ?? {}); case 'goal': if (entry.goalData?.kind === 'created') { - return new GoalSetMessageComponent(this.state.theme.colors); + return new GoalSetMessageComponent(); } if (entry.goalData?.kind === 'lifecycle') { return buildGoalMarker( entry.goalData.change, - this.state.theme.colors, this.state.toolOutputExpanded, ); } return null; case 'assistant': { if (entry.content.trimStart().startsWith('✓ Goal complete')) { - return new GoalCompletionMessageComponent(entry.content, this.state.theme.colors); + return new GoalCompletionMessageComponent(entry.content); } - const component = new AssistantMessageComponent( - this.state.theme.markdownTheme, - this.state.theme.colors, - ); + const component = new AssistantMessageComponent(); component.updateContent(entry.content); return component; } case 'thinking': { - const thinking = new ThinkingComponent(entry.content, this.state.theme.colors, true); + const thinking = new ThinkingComponent(entry.content, true); if (this.state.toolOutputExpanded) thinking.setExpanded(true); return thinking; } @@ -1334,33 +1317,25 @@ export class KimiTUI { const tc = new ToolCallComponent( entry.toolCallData, entry.toolCallData.result, - this.state.theme.colors, this.state.ui, - this.state.theme.markdownTheme, this.state.appState.workDir, ); if (this.state.toolOutputExpanded) tc.setExpanded(true); return tc; } if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'status': if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'welcome': return null; default: @@ -1413,7 +1388,7 @@ export class KimiTUI { ) { return; } - const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); + const welcome = new WelcomeComponent(this.state.appState); this.state.transcriptContainer.addChild(welcome); } @@ -1438,22 +1413,22 @@ export class KimiTUI { this.renderWelcome(); } - showStatus(message: string, color?: string): void { + showStatus(message: string, color?: ColorToken): void { this.state.transcriptContainer.addChild( - new StatusMessageComponent(message, this.state.theme.colors, color), + new StatusMessageComponent(message, color), ); this.state.ui.requestRender(); } showNotice(title: string, detail?: string): void { this.state.transcriptContainer.addChild( - new NoticeMessageComponent(title, detail, this.state.theme.colors), + new NoticeMessageComponent(title, detail), ); this.state.ui.requestRender(); } showError(message: string): void { - this.showStatus(`Error: ${message}`, this.state.theme.colors.error); + this.showStatus(`Error: ${message}`, 'error'); } showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { @@ -1461,7 +1436,7 @@ export class KimiTUI { } showProgressSpinner(label: string): LoginProgressSpinnerHandle { - const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); + const tint = (s: string): string => currentTheme.fg('primary', s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); this.state.transcriptContainer.addChild(spinner); @@ -1469,9 +1444,9 @@ export class KimiTUI { return { stop: ({ ok, label: finalLabel }) => { spinner.stop(); - const tone = ok ? this.state.theme.colors.success : this.state.theme.colors.error; + const tone = ok ? 'success' : 'error'; const symbol = ok ? '✓' : '✗'; - spinner.setText(chalk.hex(tone)(`${symbol} ${finalLabel}`)); + spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, }; @@ -1485,7 +1460,6 @@ export class KimiTUI { url: auth.verificationUriComplete, code: auth.userCode, hint: 'Press Ctrl-C to cancel', - colors: this.state.theme.colors, }), ); this.state.ui.requestRender(); @@ -1540,7 +1514,7 @@ export class KimiTUI { } case 'composing': { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => - chalk.hex(this.state.theme.colors.primary)(s), + currentTheme.fg('primary', s), ); this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( @@ -1597,7 +1571,6 @@ export class KimiTUI { this.state.queueContainer.addChild( new QueuePaneComponent({ messages: queued, - colors: this.state.theme.colors, isCompacting: this.state.appState.isCompacting, isStreaming: this.state.appState.streamingPhase !== 'idle', canSteerImmediately: !this.deferUserMessages, @@ -1618,29 +1591,31 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); - const colorToken = highlighted ? this.state.theme.colors.primary : this.state.theme.colors.border; this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); + this.state.editor.borderColor = (s: string) => + currentTheme.fg(highlighted ? 'primary' : 'border', s); this.state.ui.requestRender(); } - applyTheme(theme: Theme, resolved?: ResolvedTheme): void { - const nextTheme = createKimiTUIThemeBundle(theme, resolved); - Object.assign(this.state.theme.colors, nextTheme.colors); - this.state.theme.resolvedTheme = nextTheme.resolvedTheme; - this.state.theme.styles = nextTheme.styles; - this.state.theme.markdownTheme = nextTheme.markdownTheme; - this.setAppState({ theme }); + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise { + const palette = await getColorPalette( + themeName === 'auto' ? (resolved ?? 'dark') : themeName, + ); + currentTheme.setPalette(palette); + this.setAppState({ theme: themeName }); this.updateEditorBorderHighlight(); + // Force every historical message to re-render so Markdown/Text caches + // (which hold old ANSI colour codes) are cleared. + this.state.transcriptContainer.invalidate(); this.state.ui.requestRender(true); } refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); - if (this.state.appState.theme !== 'auto') return; + if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { - this.applyResolvedAutoTheme(resolved); + void this.applyResolvedAutoTheme(resolved); }); } @@ -1649,10 +1624,16 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } - private applyResolvedAutoTheme(resolved: ResolvedTheme): void { + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; - if (this.state.theme.resolvedTheme === resolved) return; - this.applyTheme('auto', resolved); + const palette = getBuiltInPalette(resolved); + if (currentTheme.palette === palette) return; + currentTheme.setPalette(palette); + this.updateEditorBorderHighlight(); + // Repaint already-rendered transcript entries (status/markdown caches hold + // old ANSI codes), matching applyTheme()'s behaviour. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); } private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { @@ -1742,7 +1723,6 @@ export class KimiTUI { plan, sourceHome: plan.sourceHome, targetHome: this.harness.homeDir, - colors: this.state.theme.colors, skipDecisionStep: this.migrateOnly, requestRender: () => { this.state.ui.requestRender(); @@ -1775,7 +1755,6 @@ export class KimiTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - colors: this.state.theme.colors, onClose: () => { this.hideHelpPanel(); }, @@ -1829,7 +1808,6 @@ export class KimiTUI { sessions: this.state.sessions, loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, - colors: this.state.theme.colors, onSelect: (sessionId: string) => { void this.resumeSession(sessionId).then((switched) => { if (switched) { @@ -1855,7 +1833,6 @@ export class KimiTUI { (response: ApprovalPanelResponse) => { this.approvalController.respond(adaptPanelResponse(response)); }, - this.state.theme.colors, () => { this.toggleToolOutputExpansion(); }, @@ -1887,7 +1864,6 @@ export class KimiTUI { const viewer = new ApprovalPreviewViewer( { block, - colors: this.state.theme.colors, onClose: () => { this.closeApprovalPreview(); }, @@ -1924,8 +1900,7 @@ export class KimiTUI { (response) => { this.questionController.respond(response); }, - this.state.theme.colors, - undefined, + 6, () => { this.toggleToolOutputExpansion(); }, diff --git a/apps/kimi-code/src/tui/theme/bundle.ts b/apps/kimi-code/src/tui/theme/bundle.ts deleted file mode 100644 index 8cfd0e921..000000000 --- a/apps/kimi-code/src/tui/theme/bundle.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MarkdownTheme } from '@earendil-works/pi-tui'; - -import { getColorPalette, type ColorPalette, type ResolvedTheme } from './colors'; -import { createMarkdownTheme } from './pi-tui-theme'; -import { createThemeStyles, type ThemeStyles } from './styles'; -import { resolveThemeSync, type Theme } from './index'; - -export interface KimiTUIThemeBundle { - resolvedTheme: ResolvedTheme; - colors: ColorPalette; - styles: ThemeStyles; - markdownTheme: MarkdownTheme; -} - -export function createKimiTUIThemeBundle( - theme: Theme, - resolvedTheme?: ResolvedTheme, -): KimiTUIThemeBundle { - const actualTheme = resolvedTheme ?? resolveThemeSync(theme); - const colors = { ...getColorPalette(actualTheme) }; - return { - resolvedTheme: actualTheme, - colors, - styles: createThemeStyles(colors), - markdownTheme: createMarkdownTheme(colors), - }; -} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 4a9b37bad..215c54bbb 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -1,148 +1,133 @@ /** * Color palette definitions for dark and light themes. * - * Two layers: - * - private `dark` / `light` raw palettes — unsemantic constants reused - * across multiple semantic tokens to avoid hex literal duplication. - * - exported `darkColors` / `lightColors` — the semantic `ColorPalette` - * consumed by every UI component via chalk.hex(...). + * `darkColors` / `lightColors` are the semantic `ColorPalette` consumed by + * every UI component via the global Theme singleton. Each token holds its hex + * value directly — see the per-token docs on `ColorPalette` for what each one + * controls. * * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF * for text tokens and ≥ 3:1 for chrome (border / large text), matching * WCAG AA. */ -const dark = { - blue400: '#4FA8FF', - cyan400: '#5BC0BE', - gray50: '#F5F5F5', - gray100: '#E0E0E0', - gray500: '#888888', - gray600: '#6B6B6B', - gray700: '#5A5A5A', - green400: '#4EC87E', - green300: '#7AD99B', - red400: '#E85454', - red300: '#F08585', - amber400: '#E8A838', - orange300: '#FFCB6B', -} as const; - -const light = { - blue600: '#1565C0', - cyan700: '#00838F', - gray900: '#1A1A1A', - gray700: '#454545', - gray600: '#5F5F5F', - gray500: '#737373', - green700: '#0E7A38', - red700: '#B91C1C', - amber800: '#92660A', - orange700: '#9A4A00', -} as const; - +// Each token below documents where it is actually consumed, so theme authors +// know what changing it affects. "Widely" means the token is read across most +// dialogs/messages rather than in one specific place. export interface ColorPalette { - // Brand + // ── Brand ── + /** Dominant interactive/brand colour: links & inline code, the selected item + * in nearly every dialog, the focused editor border, plan/"running" badges, + * spinners. The most widely used token. */ primary: string; + /** Secondary highlight: approval "▶" prefix, device-code box, image + * placeholder, BTW / queue panes, custom-registry import. */ accent: string; - // Text + // ── Text ── + /** Default body text: dialog bodies, todo titles, footer model label, + * markdown headings, tool/read output, and assistant-side message bullets + * (assistant / tool / agent / read) plus markdown list bullets. */ text: string; + /** Emphasised / bold text: input dialogs, status messages. */ textStrong: string; + /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, + * hints, descriptions, completed todos, markdown quotes, and the footer + * status bar (cwd path, git badge). */ textDim: string; + /** Faintest text: counters, scroll info, descriptions, markdown link URLs, + * code-block borders. */ textMuted: string; - // Surface + // ── Surface ── + /** Borders: pane & editor borders, markdown horizontal rule. */ border: string; + /** Focus / attention border — currently only the approval panel. */ borderFocus: string; - // State + // ── State ── + /** Success: ✓ marks, "enabled", completed states. */ success: string; + /** Warning: auto/yolo badges, stale markers, plan-mode hint. */ warning: string; + /** Error: error messages, failed tool output. */ error: string; - // Diff + // ── Diff (all consumed by components/media/diff-preview.ts) ── + /** Added lines. */ diffAdded: string; + /** Removed lines. */ diffRemoved: string; + /** Added lines — intra-line changed words (bold). */ diffAddedStrong: string; + /** Removed lines — intra-line changed words (bold). */ diffRemovedStrong: string; + /** Line-number gutter (also approval panel/preview). */ diffGutter: string; + /** Meta / hunk headers. */ diffMeta: string; - // Roles + // ── Roles ── + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; - roleAssistant: string; - roleThinking: string; - roleTool: string; - - // Status - status: string; } export const darkColors: ColorPalette = { - primary: dark.blue400, - accent: dark.cyan400, + primary: '#4FA8FF', + accent: '#5BC0BE', - text: dark.gray100, - textStrong: dark.gray50, - textDim: dark.gray500, - textMuted: dark.gray600, + text: '#E0E0E0', + textStrong: '#F5F5F5', + textDim: '#888888', + textMuted: '#6B6B6B', - border: dark.gray700, - borderFocus: dark.amber400, + border: '#5A5A5A', + borderFocus: '#E8A838', - success: dark.green400, - warning: dark.amber400, - error: dark.red400, + success: '#4EC87E', + warning: '#E8A838', + error: '#E85454', - diffAdded: dark.green400, - diffRemoved: dark.red400, - diffAddedStrong: dark.green300, - diffRemovedStrong: dark.red300, - diffGutter: dark.gray600, - diffMeta: dark.gray500, + diffAdded: '#4EC87E', + diffRemoved: '#E85454', + diffAddedStrong: '#7AD99B', + diffRemovedStrong: '#F08585', + diffGutter: '#6B6B6B', + diffMeta: '#888888', - roleUser: dark.orange300, - roleAssistant: dark.gray100, - roleThinking: dark.gray500, - roleTool: dark.amber400, - - status: dark.gray500, + roleUser: '#FFCB6B', }; export const lightColors: ColorPalette = { - primary: light.blue600, - accent: light.cyan700, + primary: '#1565C0', + accent: '#00838F', - text: light.gray900, - textStrong: light.gray900, - textDim: light.gray700, - textMuted: light.gray600, + text: '#1A1A1A', + textStrong: '#1A1A1A', + textDim: '#454545', + textMuted: '#5F5F5F', - border: light.gray500, - borderFocus: light.amber800, + border: '#737373', + borderFocus: '#92660A', - success: light.green700, - warning: light.amber800, - error: light.red700, + success: '#0E7A38', + warning: '#92660A', + error: '#B91C1C', - diffAdded: light.green700, - diffRemoved: light.red700, - diffAddedStrong: light.green700, - diffRemovedStrong: light.red700, - diffGutter: light.gray500, - diffMeta: light.gray600, + diffAdded: '#0E7A38', + diffRemoved: '#B91C1C', + diffAddedStrong: '#0E7A38', + diffRemovedStrong: '#B91C1C', + diffGutter: '#737373', + diffMeta: '#5F5F5F', - roleUser: light.orange700, - roleAssistant: light.gray900, - roleThinking: light.gray700, - roleTool: light.amber800, - - status: light.gray700, + roleUser: '#9A4A00', }; export type ResolvedTheme = 'dark' | 'light'; -export function getColorPalette(theme: ResolvedTheme): ColorPalette { - return theme === 'dark' ? darkColors : lightColors; +/** Synchronous palette lookup for built-in themes only. */ +export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? darkColors : lightColors; } diff --git a/apps/kimi-code/src/tui/theme/custom-theme-loader.ts b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts new file mode 100644 index 000000000..cc0b07cbf --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,97 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readdirSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + /** Built-in palette that unspecified tokens fall back to. Defaults to `dark`. */ + base: z.enum(['dark', 'light']).optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/** + * Names reserved for built-in themes. A `dark.json` / `light.json` / + * `auto.json` file would collide with the built-in value, so it can never be + * selected as a custom theme — hide it from listings. + */ +const RESERVED_THEME_NAMES: ReadonlySet = new Set(['dark', 'light', 'auto']); + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +interface ParsedCustomTheme { + readonly base: ResolvedTheme; + readonly colors: Partial; +} + +async function readCustomTheme(name: string): Promise { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + // Invalid hex values are dropped (the token falls back to the base + // palette). We intentionally do not print here: this loader can run while + // pi-tui owns the terminal, where raw stdout/stderr writes corrupt the + // rendered screen. Authoring-time validation lives in the JSON schema. + const colors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ) as Partial; + + return { base: parsed.base ?? 'dark', colors }; + } catch { + return null; + } +} + +export async function loadCustomTheme(name: string): Promise | null> { + return (await readCustomTheme(name))?.colors ?? null; +} + +/** Load a custom theme and merge it onto its base palette (dark unless `base` says otherwise). */ +export async function loadCustomThemeMerged(name: string): Promise { + const parsed = await readCustomTheme(name); + if (parsed === null) return null; + return { ...getBuiltInPalette(parsed.base), ...parsed.colors }; +} + +function toThemeNames(files: readonly string[]): string[] { + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => !RESERVED_THEME_NAMES.has(name)); +} + +export async function listCustomThemes(): Promise { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} + +/** Synchronous variant for UI paths (e.g. the `/theme` picker) that cannot await. */ +export function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index 5cd97384f..e016def5d 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,46 +2,62 @@ * Theme system public API. */ -import type { ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; import { detectTerminalTheme } from './detect'; -export { darkColors, lightColors, getColorPalette } from './colors'; +export { currentTheme, Theme } from './theme'; +export type { ColorToken } from './theme'; +export { darkColors, lightColors, getBuiltInPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; -export { createThemeStyles } from './styles'; -export type { ThemeStyles } from './styles'; -export { gradientText } from './gradient-text'; -export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; /** - * User-facing theme preference. `'auto'` defers to terminal background - * detection at startup; `'dark'` / `'light'` are explicit overrides that - * never trigger detection. The persisted value in `tui.toml` is always - * one of these three; the detected `ResolvedTheme` is computed at - * startup and held only in memory. + * User-facing theme preference. + * `'auto'` defers to terminal background detection at startup. + * `'dark'` / `'light'` are explicit built-in overrides. + * Any other string is treated as a custom theme name looked up in + * `~/.kimi-code/themes/.json`. */ -export type Theme = 'dark' | 'light' | 'auto'; +export type BuiltInTheme = 'dark' | 'light' | 'auto'; +export type ThemeName = BuiltInTheme | (string & {}); -export function isTheme(value: string): value is Theme { +export function isBuiltInTheme(value: string): value is BuiltInTheme { return value === 'dark' || value === 'light' || value === 'auto'; } -/** - * Resolve a user preference to a concrete palette key. `'auto'` triggers - * terminal background detection (OSC 11 with COLORFGBG / dark fallback); - * explicit choices pass through. - */ -export async function resolveTheme(theme: Theme): Promise { - if (theme === 'auto') return detectTerminalTheme(); - return theme; +export function isThemeName(_value: string): _value is ThemeName { + return true; // any string is a valid theme name (custom themes) } /** - * Synchronous fallback used by paths that cannot wait on terminal probes - * (initial state construction, in-TUI theme switches). `'auto'` collapses - * to `'dark'`; explicit choices pass through. + * Resolve a user preference to a concrete palette. + * + * - `'auto'` triggers terminal background detection. + * - `'dark'` / `'light'` return the built-in palette. + * - Any other string loads a custom theme from `~/.kimi-code/themes/`; + * missing / invalid files fall back to dark palette. */ -export function resolveThemeSync(theme: Theme): ResolvedTheme { - if (theme === 'auto') return 'dark'; - return theme; +export async function getColorPalette(theme: ThemeName): Promise { + if (theme === 'light') return getBuiltInPalette('light'); + if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'auto') { + const detected = await detectTerminalTheme(); + return getBuiltInPalette(detected); + } + // custom theme + const custom = await loadCustomThemeMerged(theme); + return custom ?? getBuiltInPalette('dark'); +} + +/** + * Synchronous fallback used by paths that cannot wait on terminal probes. + * `'auto'` collapses to `'dark'`; explicit choices pass through. + * Custom themes are not supported here — falls back to dark. + */ +export function getColorPaletteSync(theme: ThemeName): ColorPalette { + if (theme === 'light') return getBuiltInPalette('light'); + return getBuiltInPalette('dark'); } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index dc3b1b9ad..dec6ab253 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -1,15 +1,18 @@ /** - * Pi-tui theme adapters — MarkdownTheme and EditorTheme from our ColorPalette. + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. * - * All chalk calls route through `ColorPalette` tokens so themes flip - * cleanly. No raw `chalk.gray` / `chalk.dim` / `chalk.white` here. + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. */ import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; -import type { ColorPalette } from './colors'; +import { currentTheme } from './theme'; // pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 // headings (h1/h2 are rendered without the `#` prefix). The prefix arrives @@ -19,25 +22,23 @@ import type { ColorPalette } from './colors'; // eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; -export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { +export function createMarkdownTheme(): MarkdownTheme { const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - const muted = chalk.hex(colors.textMuted); - const dim = chalk.hex(colors.textDim); - const border = chalk.hex(colors.border); + return { - heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)), - link: (text) => chalk.hex(colors.primary)(text), - linkUrl: (text) => muted(text), - code: (text) => chalk.hex(colors.primary)(text), + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), codeBlock: (text) => text, - codeBlockBorder: (text) => muted(text), - quote: (text) => dim(text), - quoteBorder: (text) => dim(text), - hr: (text) => border(text), + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), // Match the assistant-message bullet so list markers read like a reply - // prefix. Ordered lists arrive as `"1. "` / `"2. "` and are left + // prefix. Ordered lists arrive as "1. " / "2. " and are left // untouched by the leading-dash anchor. - listBullet: (text) => chalk.hex(colors.roleAssistant)(text.replace(/^-/, '•')), + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), bold: (text) => chalk.bold(text), italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), @@ -56,16 +57,15 @@ export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { }; } -export function createEditorTheme(colors: ColorPalette): EditorTheme { - const muted = chalk.hex(colors.textMuted); +export function createEditorTheme(): EditorTheme { return { - borderColor: (s) => chalk.hex(colors.border)(s), + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), selectList: { - selectedPrefix: (s) => chalk.hex(colors.primary)(s), - selectedText: (s) => chalk.hex(colors.primary)(s), - description: (s) => muted(s), - scrollInfo: (s) => muted(s), - noMatch: (s) => muted(s), + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), }, }; } diff --git a/apps/kimi-code/src/tui/theme/styles.ts b/apps/kimi-code/src/tui/theme/styles.ts deleted file mode 100644 index 625302e45..000000000 --- a/apps/kimi-code/src/tui/theme/styles.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Theme-aware style helpers built on chalk. Components hold a reference - * to a `ThemeStyles` instance via `state.theme.styles` and never reach into - * raw chalk color names — that keeps theme switches consistent and lets - * every visual token route through `ColorPalette`. - */ - -import chalk from 'chalk'; - -import type { ColorPalette } from './colors'; - -export interface ThemeStyles { - colors: ColorPalette; - - /** Brand primary (links, focus, slash highlight). */ - primary(text: string): string; - /** Secondary brand accent (command operators, approval labels). */ - accent(text: string): string; - /** Dimmed text — secondary but still readable. */ - dim(text: string): string; - /** Muted text — most faded; for unchanged-line counters, scroll info. */ - muted(text: string): string; - /** Body text — same color as default but explicit for theming. */ - text(text: string): string; - /** Strong / emphasized text — paths, URLs, command bodies. */ - strong(text: string): string; - - error(text: string): string; - warning(text: string): string; - success(text: string): string; - - /** Bold + dim, for label cells. */ - label(text: string): string; - /** Body color, for value cells. */ - value(text: string): string; - - diffAdd(text: string): string; - diffDel(text: string): string; - diffAddBold(text: string): string; - diffDelBold(text: string): string; - diffGutter(text: string): string; - diffMeta(text: string): string; -} - -export function createThemeStyles(colors: ColorPalette): ThemeStyles { - return { - colors, - primary: (s) => chalk.hex(colors.primary)(s), - accent: (s) => chalk.hex(colors.accent)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - muted: (s) => chalk.hex(colors.textMuted)(s), - text: (s) => chalk.hex(colors.text)(s), - strong: (s) => chalk.hex(colors.textStrong)(s), - error: (s) => chalk.hex(colors.error)(s), - warning: (s) => chalk.hex(colors.warning)(s), - success: (s) => chalk.hex(colors.success)(s), - label: (s) => chalk.bold.hex(colors.textDim)(s), - value: (s) => chalk.hex(colors.text)(s), - diffAdd: (s) => chalk.hex(colors.diffAdded)(s), - diffDel: (s) => chalk.hex(colors.diffRemoved)(s), - diffAddBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - diffDelBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - diffGutter: (s) => chalk.hex(colors.diffGutter)(s), - diffMeta: (s) => chalk.hex(colors.diffMeta)(s), - }; -} diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json new file mode 100644 index 000000000..5e320992d --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moonshot-ai/kimi-code/blob/main/apps/kimi-code/src/tui/theme/theme-schema.json", + "title": "Kimi Code Custom Theme", + "description": "Schema for Kimi Code TUI custom theme definitions", + "type": "object", + "required": ["name"], + "properties": { + "$schema": { + "type": "string", + "description": "URL to this JSON Schema" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable theme identifier (kebab-case recommended)" + }, + "displayName": { + "type": "string", + "description": "Human-readable theme name shown in UI" + }, + "base": { + "type": "string", + "enum": ["dark", "light"], + "description": "Built-in palette that unspecified tokens fall back to (default: dark)" + }, + "colors": { + "type": "object", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, + "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, + "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, + "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, + "textMuted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Most faded text; for counters, scroll info" }, + "border": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Border color" }, + "borderFocus": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Focused border color" }, + "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, + "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, + "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, + "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, + "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, + "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, + "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, + "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, + "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" } + }, + "additionalProperties": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Any valid ColorPalette token" + } + } + }, + "additionalProperties": false +} diff --git a/apps/kimi-code/src/tui/theme/theme.ts b/apps/kimi-code/src/tui/theme/theme.ts new file mode 100644 index 000000000..3b7a377fe --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme.ts @@ -0,0 +1,93 @@ +/** + * Theme class + global singleton. + * + * Components import `currentTheme` and call methods like + * `currentTheme.fg('primary', text)` at render time. When the user switches + * themes we call `currentTheme.setPalette(newPalette)` — the same singleton + * instance stays alive, so every component (including already-rendered + * transcript entries) sees the new colours on the next render frame. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export type ColorToken = keyof ColorPalette; + +export class Theme { + private _palette: ColorPalette; + + constructor(palette: ColorPalette) { + this._palette = palette; + } + + get palette(): ColorPalette { + return this._palette; + } + + setPalette(palette: ColorPalette): void { + this._palette = palette; + } + + color(token: ColorToken): string { + return this._palette[token]; + } + + /* ── Foreground helpers ── */ + + fg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token])(text); + } + + boldFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).bold(text); + } + + dimFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).dim(text); + } + + italicFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).italic(text); + } + + underlineFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).underline(text); + } + + strikethroughFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).strikethrough(text); + } + + /* ── Background helpers ── */ + + bg(token: ColorToken, text: string): string { + return chalk.bgHex(this._palette[token])(text); + } + + /* ── Standalone style helpers ── */ + + bold(text: string): string { + return chalk.bold(text); + } + + dim(text: string): string { + return chalk.dim(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } +} + +/** Global singleton. Initialise with dark palette; switch via `setPalette`. */ +export const currentTheme = new Theme(darkColors); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index a3bf88c24..8ff43694b 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -12,7 +12,7 @@ import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; -import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { currentTheme, type Theme } from './theme'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -36,7 +36,7 @@ export interface TUIState { editorContainer: Container; footer: FooterComponent; editor: CustomEditor; - theme: KimiTUIThemeBundle; + theme: Theme; appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; @@ -55,7 +55,7 @@ export interface TUIState { export function createTUIState(options: KimiTUIOptions): TUIState { const initialAppState = options.initialAppState; - const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + const theme = currentTheme; const terminal = new ProcessTerminal(); const ui = new TUI(terminal); @@ -63,12 +63,12 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanel = new TodoPanelComponent(theme.colors); + const todoPanel = new TodoPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui, theme.colors); - const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { + const editor = new CustomEditor(ui); + const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); @@ -82,8 +82,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { queueContainer, btwPanelContainer, editorContainer, - footer, editor, + footer, theme, appState: { ...initialAppState }, startupState: 'pending', diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 3c65fa67f..8bf127096 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -10,8 +10,7 @@ import type { import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; -import type { Theme } from './theme'; -import type { ResolvedTheme } from './theme/colors'; +import type { ColorToken, ThemeName } from './theme'; export interface AppState { model: string; @@ -28,7 +27,7 @@ export interface AppState { isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; streamingStartTime: number; - theme: Theme; + theme: ThemeName; version: string; editorCommand: string | null; notifications: NotificationsConfig; @@ -134,7 +133,7 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; - color?: string; + color?: ColorToken; detail?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; @@ -193,7 +192,6 @@ export type TUIStartupState = 'pending' | 'ready' | 'picker'; export interface KimiTUIOptions { initialAppState: AppState; startup: TUIStartupOptions; - resolvedTheme?: ResolvedTheme; } export interface PendingExit { diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index b6404c97d..afbda67c0 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -207,7 +207,7 @@ max_context_size = 0 `, 'utf-8', ); - await writeFile(join(dir, 'tui.toml'), 'theme = "blue"\n', 'utf-8'); + await writeFile(join(dir, 'tui.toml'), 'editor = 123\n', 'utf-8'); const { deps, stdout, stderr } = makeDeps(); const code = await handleDoctor(deps, {}); @@ -219,14 +219,14 @@ max_context_size = 0 expect(err).toContain(`ERROR config.toml ${join(dir, 'config.toml')}`); expect(err).toContain('max_context_size'); expect(err).toContain(`ERROR tui.toml ${join(dir, 'tui.toml')}`); - expect(err).toContain('theme'); + expect(err).toContain('editor'); }); it('formats Zod validation issues with field paths for tui.toml', async () => { await writeFile( join(dir, 'tui.toml'), ` -theme = "blue" +editor = 123 [notifications] enabled = "yes" @@ -240,7 +240,7 @@ enabled = "yes" expect(code).toBe(1); const err = stderr.join(''); expect(err).toContain('Validation issues:'); - expect(err).toContain('theme:'); + expect(err).toContain('editor:'); expect(err).toContain('notifications.enabled:'); }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c55a5e590..b61641dd0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -224,7 +224,6 @@ describe('runShell', () => { }, version: '1.2.3-test', workDir: process.cwd(), - resolvedTheme: 'dark', }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); @@ -476,7 +475,6 @@ describe('runShell', () => { const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', - resolvedTheme: 'light', tuiConfig: { theme: 'auto', editorCommand: 'vim', diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index f450b099c..da70d5309 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -35,7 +35,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -55,7 +54,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -69,7 +67,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -85,7 +82,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -104,7 +100,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -123,7 +118,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 1365 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1: Migrate now -> ask2 @@ -140,7 +134,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 0 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1 -> ask2 @@ -155,7 +148,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, onComplete: () => {}, }); @@ -171,7 +163,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, runMigration: async (input) => { captured = input; @@ -191,7 +182,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // expose progress rendering via the test hook (see Step 5.2) @@ -211,7 +201,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, // A migration that never settles keeps the screen in the progress // phase so the spinner animation can be observed. @@ -236,7 +225,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testEnterProgress(); @@ -312,7 +300,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport()); @@ -327,7 +314,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -361,7 +347,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -377,7 +362,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // config skipped (e.g. a malformed legacy config.toml). @@ -413,7 +397,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -454,7 +437,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -496,7 +478,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); @@ -511,7 +492,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -544,7 +524,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); @@ -561,7 +540,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { onCompleteResult = r; }, @@ -583,7 +561,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, runMigration: async () => { throw new Error('boom'); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index c8e13a305..2b12a76ee 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -35,7 +35,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 4c8233096..89bf62da6 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -34,7 +34,7 @@ function makeHost() { }; const host = { state: { - theme: { colors: darkColors }, + theme: { palette: darkColors }, ui: { requestRender: vi.fn() }, }, harness: { @@ -110,7 +110,7 @@ describe('experimental feature command handlers', () => { expect(host.harness.setConfig).not.toHaveBeenCalled(); expect(host.showStatus).toHaveBeenCalledWith( 'No experimental feature changes to apply.', - darkColors.textMuted, + 'textMuted', ); }); }); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 3571a94c6..bad8e506b 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -16,7 +16,7 @@ import { updateGoalQueueItem, } from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; vi.mock('#/tui/goal-queue-store', () => ({ appendGoalQueueItem: vi.fn(async () => ({ @@ -108,7 +108,7 @@ function makeHost( }, transcriptContainer, ui: { requestRender: vi.fn() }, - theme: { colors: getColorPalette('dark') }, + theme: { palette: getBuiltInPalette('dark') }, }, session: hasSession ? session : undefined, skillCommandMap: new Map(), diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 317d2bea7..ab54a221b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -8,6 +8,7 @@ import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; +import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { isExperimentalFlagEnabled, @@ -60,7 +61,7 @@ auto_install = false }); expect(host.showStatus).toHaveBeenCalledWith( 'TUI config reloaded.', - host.state.theme.colors.success, + 'success', ); }); @@ -85,6 +86,31 @@ auto_install = false fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, }); }); + + it('awaits the async theme application before refreshing terminal tracking', async () => { + await writeTuiConfig('theme = "auto"\n'); + const host = makeHost(); + const mutable = host as unknown as { + applyTheme: (theme: string) => Promise; + refreshTerminalThemeTracking: () => void; + state: { appState: { theme: string } }; + }; + + let themeWhenTracked: string | undefined; + // Theme application resolves on a later microtask, mirroring the real + // async palette load; tracking must observe the *new* theme. + mutable.applyTheme = vi.fn(async (theme: string) => { + await Promise.resolve(); + mutable.state.appState.theme = theme; + }); + mutable.refreshTerminalThemeTracking = vi.fn(() => { + themeWhenTracked = mutable.state.appState.theme; + }); + + await handleReloadTuiCommand(host); + + expect(themeWhenTracked).toBe('auto'); + }); }); async function writeTuiConfig(text: string): Promise { @@ -110,8 +136,7 @@ function makeHost({ availableProviders: {}, }, theme: { - resolvedTheme: 'dark', - colors: { + palette: { success: '#00ff00', }, }, diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 3c9418989..c9427a710 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { handleSwarmCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const ENTER = '\r'; const ESCAPE = '\u001B'; @@ -36,7 +36,7 @@ function makeHost( permissionMode: overrides.permissionMode ?? 'auto', swarmMode: overrides.swarmMode ?? false, }, - theme: { colors: getColorPalette('dark') }, + theme: currentTheme, transcriptContainer: { addChild: vi.fn() }, ui: { requestRender: vi.fn() }, }, diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index 910fc4a58..fdb64ce46 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -30,7 +30,7 @@ describe('update preference commands', () => { notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, }, - theme: { colors: darkColors }, + theme: { palette: darkColors }, }, setAppState, showStatus, diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index f7cea9b92..9dda8ff28 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -19,7 +19,6 @@ describe('DeviceCodeBoxComponent', () => { url, code, hint, - colors: darkColors, }); const lines = component.render(80).map(strip); @@ -42,7 +41,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const lines = component.render(40).map(strip); @@ -57,7 +55,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const joined = component.render(80).map(strip).join('\n'); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index e5d56b0ed..ab0878d6b 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -71,7 +71,7 @@ describe('FooterComponent', () => { it('paints the model name in rainbow while colored', () => { setDanceView(true, 0); - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -82,11 +82,25 @@ describe('FooterComponent', () => { }); it('renders the model name in its normal color when not dancing', () => { - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); expect(codes.has(RAINBOW_CYAN)).toBe(false); expect(codes.has(RAINBOW_GREEN)).toBe(false); }); + + it('repaints from the active palette on the next render (no setColors needed)', () => { + const footer = new FooterComponent(appState); + const before = footer.render(120).join('\n'); + + currentTheme.setPalette(lightColors); + try { + const after = footer.render(120).join('\n'); + // Reads currentTheme live, so a palette swap changes the emitted colours. + expect(after).not.toBe(before); + } finally { + currentTheme.setPalette(darkColors); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 530f9281e..4233934db 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -71,7 +71,7 @@ describe('WelcomeComponent', () => { }); it('renders the banner in a single brand color by default', () => { - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); // No rainbow by default — just the brand primary (plus the dim tagline). expect(codes.size).toBeLessThanOrEqual(2); @@ -79,15 +79,15 @@ describe('WelcomeComponent', () => { it('paints the banner in rainbow while colored', () => { setDanceView(true, 0); - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); }); it('renders exactly the default banner when not colored', () => { - const base = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const base = headerOf(new WelcomeComponent(appState).render(80)); setDanceView(false, 5); - const off = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 9454ab316..473f3261e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -7,12 +7,9 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import { getColorPalette } from '#/tui/theme/colors'; import { captureProcessWrite } from '../../../helpers/process'; -const COLORS = getColorPalette('dark'); - function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -52,7 +49,6 @@ function makeDialog(): { const dialog = new ApprovalPanelComponent( makePending(), (response) => responses.push(response), - COLORS, ); return { dialog, responses }; } @@ -84,7 +80,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Dangerous: recursive delete'); @@ -113,7 +109,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const rendered = dialog.render(60); const out = strip(rendered.join('\n')); @@ -216,7 +212,7 @@ describe('ApprovalPanelComponent', () => { ], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Ready to build with this plan?'); @@ -264,7 +260,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, () => toolOutputToggles++, (block) => previewCalls.push(block), ); @@ -307,7 +302,7 @@ describe('ApprovalPanelComponent', () => { }, }; let globalToggleCalls = 0; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); + const dialog = new ApprovalPanelComponent(pending, () => {}, () => globalToggleCalls++); dialog.handleInput('\u000F'); // Ctrl+O @@ -333,7 +328,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -366,7 +360,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -408,7 +401,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -451,7 +443,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (response) => responses.push(response), - COLORS, ); dialog.handleInput('2'); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index e3f216a1e..d4ba77fa9 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -5,10 +5,6 @@ import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from '#/tui/components/dialogs/approval-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); - const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); @@ -49,7 +45,6 @@ function makeViewer(opts: { return new ApprovalPreviewViewer( { block: opts.block, - colors: COLORS, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 24, opts.columns ?? 100), diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index bc119edb4..367a85578 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -22,7 +22,6 @@ describe('ChoicePickerComponent', () => { { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, ], - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -61,7 +60,6 @@ describe('ChoicePickerComponent', () => { }, ], currentValue: 'manual', - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -79,7 +77,6 @@ describe('ChoicePickerComponent', () => { const editor = new EditorSelectorComponent({ currentValue: 'vim', - colors: darkColors, onSelect, onCancel, }); @@ -87,7 +84,6 @@ describe('ChoicePickerComponent', () => { const theme = new ThemeSelectorComponent({ currentValue: 'light', - colors: darkColors, onSelect, onCancel, }); @@ -95,14 +91,12 @@ describe('ChoicePickerComponent', () => { const permission = new PermissionSelectorComponent({ currentValue: 'manual', - colors: darkColors, onSelect, onCancel, }); expect(permission.render(120).map(strip)).toContain(' ❯ Manual ← current'); const settings = new SettingsSelectorComponent({ - colors: darkColors, onSelect, onCancel, }); @@ -113,7 +107,6 @@ describe('ChoicePickerComponent', () => { const upgradePreference = new UpdatePreferenceSelectorComponent({ currentValue: true, - colors: darkColors, onSelect, onCancel, }); @@ -131,7 +124,6 @@ describe('ChoicePickerComponent', () => { { value: 'azure', label: 'Azure OpenAI' }, ], searchable: true, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -145,7 +137,6 @@ describe('ChoicePickerComponent', () => { const picker = new ChoicePickerComponent({ title: 'Pick one', options: [{ value: 'a', label: 'Alpha' }], - colors: darkColors, onSelect, onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index b49501978..80aaa6e79 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; +import { afterEach, describe, expect, it } from 'vitest'; import { CompactionComponent } from '#/tui/components/dialogs/compaction'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -9,7 +14,7 @@ function strip(text: string): string { describe('CompactionComponent', () => { it('renders the custom instruction below the compacting label', () => { - const component = new CompactionComponent(darkColors, undefined, 'keep the recent files only'); + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { const lines = component.render(120).map(strip); @@ -23,7 +28,7 @@ describe('CompactionComponent', () => { }); it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(darkColors); + const component = new CompactionComponent(); try { component.markCanceled(); @@ -36,4 +41,32 @@ describe('CompactionComponent', () => { component.dispose(); } }); + + it('repaints the header with the active palette on invalidate', () => { + // Force truecolor so palette differences surface as ANSI codes even when + // the test runner has no TTY. + const previousLevel = chalk.level; + chalk.level = 3; + const component = new CompactionComponent(); + + try { + const headerOf = (): string => { + const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); + if (line === undefined) throw new Error('header line not found'); + return line; + }; + const before = headerOf(); + + currentTheme.setPalette(lightColors); + component.invalidate(); + const after = headerOf(); + + // Same visible text, different ANSI colour codes. + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + component.dispose(); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index 66bc3badd..848c8f5e1 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -23,7 +23,6 @@ function makeDialog(defaultUrl = 'https://example.com/api.json'): { const onDone = vi.fn(); const dialog = new CustomRegistryImportDialogComponent( onDone as unknown as (r: CustomRegistryImportResult) => void, - darkColors, defaultUrl, ); dialog.focused = true; diff --git a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts index ce402d3dd..f80c3a3da 100644 --- a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -5,7 +5,7 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '#/tui/components/dialogs/experiments-selector'; -import { darkColors } from '#/tui/theme/colors'; + const ANSI = /\u001B\[[0-9;]*m/g; const ESC = String.fromCodePoint(27); @@ -41,7 +41,6 @@ describe('ExperimentsSelectorComponent', () => { features: [ feature({ enabled: true, source: 'config', configValue: true }), ], - colors: darkColors, onApply: vi.fn(), onCancel: vi.fn(), }); @@ -61,7 +60,6 @@ describe('ExperimentsSelectorComponent', () => { const first = feature(); const selector = new ExperimentsSelectorComponent({ features: [first], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -91,7 +89,6 @@ describe('ExperimentsSelectorComponent', () => { source: 'env', }), ], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -108,7 +105,6 @@ describe('ExperimentsSelectorComponent', () => { const onCancel = vi.fn(); const selector = new ExperimentsSelectorComponent({ features: [feature()], - colors: darkColors, onApply: vi.fn(), onCancel, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 13fcdd589..5b0fdeeb1 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -26,7 +26,7 @@ function makeDialog(): { const collected: FeedbackInputDialogResult[] = []; const dialog = new FeedbackInputDialogComponent((result) => { collected.push(result); - }, darkColors); + }); dialog.focused = true; return { dialog, collected }; } diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 23062ea2b..258f044d2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -6,7 +6,6 @@ import { GoalQueueManagerComponent, type GoalQueueManagerAction, } from '#/tui/components/dialogs/goal-queue-manager'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; const ANSI = /\u001B\[[0-9;]*m/g; @@ -39,7 +38,6 @@ describe('GoalQueueManagerComponent', () => { it('renders the upcoming goals and the management hint', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'Ship queued goal')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -59,7 +57,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -85,7 +82,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -112,7 +108,6 @@ describe('GoalQueueManagerComponent', () => { ); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -130,7 +125,6 @@ describe('GoalQueueManagerComponent', () => { const onAction = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First queued goal')], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -144,7 +138,6 @@ describe('GoalQueueManagerComponent', () => { const onCancel = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [], - colors: darkColors, onAction: vi.fn(), onCancel, }); @@ -157,7 +150,6 @@ describe('GoalQueueManagerComponent', () => { it('never renders a line wider than the terminal', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'A very long queued goal objective that should be truncated cleanly')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -172,7 +164,6 @@ describe('GoalQueueManagerComponent', () => { it('renders multiline objectives as a single selectable row', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First line\nSecond line')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -189,7 +180,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -207,7 +197,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -226,7 +215,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -245,7 +233,6 @@ describe('GoalQueueEditDialogComponent', () => { it('renders multiline edits inside the dialog width', () => { const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'First line\nSecond line'), - colors: darkColors, onDone: vi.fn(), }); @@ -262,7 +249,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -276,7 +262,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', ''), - colors: darkColors, onDone, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 0a59030f6..dd4780e64 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -33,7 +33,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2') }, currentValue: 'kimi', currentThinking: true, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -51,7 +50,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', currentThinking: true, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -77,7 +75,6 @@ describe('ModelSelectorComponent', () => { models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', currentThinking: false, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -93,7 +90,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'always', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -117,7 +113,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'plain', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -140,7 +135,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'current', currentThinking: false, // thinking deliberately off on the active model - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -160,7 +154,6 @@ describe('ModelSelectorComponent', () => { models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, currentValue: 'k2', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel, @@ -188,7 +181,6 @@ describe('ModelSelectorComponent', () => { models, currentValue: 'm0', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -206,7 +198,6 @@ describe('ModelSelectorComponent', () => { }, currentValue: 'long', currentThinking: false, - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 5a033a2cf..34d7358e7 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -109,7 +109,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -148,7 +147,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel, }); @@ -175,7 +173,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -214,7 +211,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -244,7 +240,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(), source: '/tmp/marketplace.json', - colors: darkColors, onSelect: vi.fn(), onCancel, }); @@ -265,7 +260,6 @@ describe('plugins selector dialogs', () => { ], installedIds: new Set(['superpowers']), source: '/tmp/marketplace.json', - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -298,7 +292,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -329,7 +322,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -356,7 +348,6 @@ describe('plugins selector dialogs', () => { source: 'local-path', }, ], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -396,7 +387,6 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, - colors: darkColors, onSelect: (selection) => { selections.push(selection); }, @@ -434,7 +424,6 @@ describe('plugins selector dialogs', () => { ], selectedId: 'kimi-datasource', pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -449,7 +438,6 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, @@ -470,7 +458,6 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts index 858289f42..6596cf705 100644 --- a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -24,7 +24,6 @@ function rendered(component: ProviderManagerComponent, width = 120): string { function makeComponent(overrides: Partial = {}): ProviderManagerComponent { return new ProviderManagerComponent({ providers: {} as Record, - colors: darkColors, onAdd: vi.fn(), onDeleteSource: vi.fn(), onClose: vi.fn(), diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 98604f43e..12ca62ed7 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -49,7 +49,6 @@ function makeDialog( collected.push(response.answers); methods.push(response.method); }, - darkColors, 6, onToggleToolOutput, ); @@ -86,9 +85,9 @@ describe('QuestionDialogComponent', () => { expect(review).not.toContain('? Ready to submit your answers?'); expect(review).not.toContain('Please answer all questions before submitting.'); expect(reviewRaw).toContain( - chalk.hex(darkColors.text).bold(' Review your answer before submit'), + currentTheme.boldFg('text', ' Review your answer before submit'), ); - expect(reviewRaw).toContain(chalk.hex(darkColors.text)(' Ready to submit your answers?')); + expect(reviewRaw).toContain(currentTheme.fg('text', ' Ready to submit your answers?')); expect(review).toContain('B1'); expect(review).toContain('A2'); @@ -293,8 +292,8 @@ describe('QuestionDialogComponent', () => { dialog.handleInput('\u001B[D'); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.hex(darkColors.success).bold(' → [1] A')); - expect(out).not.toContain(chalk.hex(darkColors.primary)(' → [1] A')); + expect(out).toContain(currentTheme.boldFg('success', ' → [1] A')); + expect(out).not.toContain(currentTheme.fg('primary', ' → [1] A')); }); it('stretches the border to the full available width', () => { @@ -354,7 +353,9 @@ describe('QuestionDialogComponent', () => { const { dialog } = makeDialog(pending); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.bgHex(darkColors.primary).hex(darkColors.text).bold(' First ')); + expect(out).toContain( + chalk.bgHex(currentTheme.color('primary')).hex(currentTheme.color('text')).bold(' First '), + ); expect(out).not.toContain('(●) First'); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 13c014a64..4b6749000 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -2,7 +2,6 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; -import { getColorPalette } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); @@ -24,7 +23,6 @@ describe('SessionPickerComponent', () => { sessions: [], loading: false, currentSessionId: '', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), onCtrlC, @@ -59,7 +57,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -87,7 +84,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -117,7 +113,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -144,7 +139,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -178,7 +172,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_current', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -205,7 +198,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -241,7 +233,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -270,7 +261,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_cjk_long_session_id_value', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -305,7 +295,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: id, - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index d545103af..b1a2baf0c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -35,7 +35,6 @@ function make(): { }, currentValue: 'k2', currentThinking: false, - colors: darkColors, onSelect, onCancel: vi.fn(), }); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6545af266..2f82aabea 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -7,13 +7,12 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; -import { getColorPalette } from '#/tui/theme/index'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), } as unknown as TUI; - return new CustomEditor(tui, { ...getColorPalette('dark') }); + return new CustomEditor(tui); } async function flushAutocomplete(): Promise { diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index 1ac26c70e..d47f29b56 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -20,7 +20,7 @@ function expectHighlighted(out: string, token: string): void { describe('highlightFirstSlashToken', () => { it('colours /cmd when line starts with a slash', () => { - const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); + const out = highlightFirstSlashToken(' /help rest of input', 'primary'); expect(out).toBeDefined(); // Visible text unchanged expect(strip(out!)).toBe(' /help rest of input'); @@ -29,7 +29,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours next in /goal next', () => { - const out = highlightFirstSlashToken('/goal next Ship feature X', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next Ship feature X', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next Ship feature X'); expectHighlighted(out!, '/goal'); @@ -38,7 +38,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours manage in /goal next manage', () => { - const out = highlightFirstSlashToken('/goal next manage', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next manage', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next manage'); expectHighlighted(out!, '/goal'); @@ -47,19 +47,19 @@ describe('highlightFirstSlashToken', () => { }); it('returns undefined when the line has no slash', () => { - expect(highlightFirstSlashToken('hello world', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('hello world', 'primary')).toBeUndefined(); }); it('returns undefined when slash is not at the leading position', () => { - expect(highlightFirstSlashToken(' hello /not-cmd', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken(' hello /not-cmd', 'primary')).toBeUndefined(); }); it('returns undefined for path-like slash tokens', () => { - expect(highlightFirstSlashToken('/user/desktop/ foo', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('/user/desktop/ foo', 'primary')).toBeUndefined(); }); it('handles /token at end of line (no trailing whitespace)', () => { - const out = highlightFirstSlashToken('/exit', '#ff00aa'); + const out = highlightFirstSlashToken('/exit', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/exit'); }); @@ -68,7 +68,7 @@ describe('highlightFirstSlashToken', () => { // Simulate pi-tui Editor inserting an inverse-video cursor marker // somewhere after the slash token. const line = '/help x\u001B[7m \u001B[0m'; - const out = highlightFirstSlashToken(line, '#ff00aa'); + const out = highlightFirstSlashToken(line, 'primary'); expect(out).toBeDefined(); // Stripped visible content unchanged expect(strip(out!)).toBe(strip(line)); @@ -77,11 +77,11 @@ describe('highlightFirstSlashToken', () => { }); it('only paints the first token, not other slashes further along', () => { - const out = highlightFirstSlashToken('/a /b', '#ff00aa'); + const out = highlightFirstSlashToken('/a /b', 'primary'); expect(out).toBeDefined(); // Count the SGR opens — should be exactly one for /a. const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; - expect(opens).toBeGreaterThanOrEqual(2); // chalk hex+bold open and reset(s) + expect(opens).toBeGreaterThanOrEqual(2); // chalk bold+fg open and reset(s) // /b should remain plain — the substring " /b" exists verbatim. expect(out!).toContain(' /b'); }); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index f2a43aabd..8e7214e11 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -5,9 +5,6 @@ import { renderDiffLines, renderDiffLinesClustered, } from '#/tui/components/media/diff-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -46,7 +43,7 @@ describe('computeDiffLines', () => { describe('renderDiffLines', () => { it('does not show removed count for suppressed trailing deletes', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, true, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', true, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('test.ts'); expect(text).not.toContain('-2'); @@ -59,7 +56,7 @@ describe('renderDiffLines', () => { }); it('shows removed count for complete diffs', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, false, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', false, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('-2'); expect(text).toContain('C'); @@ -69,7 +66,7 @@ describe('renderDiffLines', () => { describe('renderDiffLinesClustered', () => { it('renders header with file path and counts', () => { - const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); const text = stripAnsi(out[0]!); expect(text).toContain('+1'); expect(text).toContain('-1'); @@ -77,7 +74,7 @@ describe('renderDiffLinesClustered', () => { }); it('returns header only when there are no changes', () => { - const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts'); expect(out).toHaveLength(1); expect(stripAnsi(out[0]!)).toContain('foo.ts'); }); @@ -87,7 +84,7 @@ describe('renderDiffLinesClustered', () => { const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); const text = stripAnsi( - renderDiffLinesClustered(oldText, newText, 'f.ts', COLORS, { contextLines: 1 }).join('\n'), + renderDiffLinesClustered(oldText, newText, 'f.ts', { contextLines: 1 }).join('\n'), ); expect(text).toContain('L2'); expect(text).toContain('L3'); @@ -104,7 +101,7 @@ describe('renderDiffLinesClustered', () => { newLines[1] = 'L2X'; // change near top newLines[28] = 'L29X'; // change near bottom const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'), ); @@ -121,7 +118,7 @@ describe('renderDiffLinesClustered', () => { const newLines = oldLines.slice(); newLines[2] = 'L3X'; newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) - const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'); const text = stripAnsi(out); @@ -144,7 +141,6 @@ describe('renderDiffLinesClustered', () => { oldLines.join('\n'), newLines.join('\n'), 'big.ts', - COLORS, { contextLines: 3, maxLines: 10, @@ -167,7 +163,7 @@ describe('renderDiffLinesClustered', () => { newLines[20] = 'L21X'; newLines[40] = 'L41X'; const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, maxLines: 6, }).join('\n'), diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index c89e77a36..485a171ba 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { AgentSwarmProgressComponent, @@ -12,7 +13,7 @@ import { calculateAgentSwarmGridLayout, } from '#/tui/components/messages/agent-swarm-progress'; import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; const DEFAULT_DESCRIPTION = 'Review changed files'; @@ -25,7 +26,6 @@ function createComponent( ): AgentSwarmProgressComponent { return new AgentSwarmProgressComponent({ description: options.description ?? DEFAULT_DESCRIPTION, - colors: options.colors ?? darkColors, requestRender: options.requestRender, availableGridHeight: options.availableGridHeight, }); @@ -57,6 +57,7 @@ function startSubagents(component: AgentSwarmProgressComponent, count: number): afterEach(() => { vi.useRealTimers(); + currentTheme.setPalette(darkColors); }); describe('calculateAgentSwarmGridLayout', () => { @@ -166,6 +167,29 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); + it('repaints from the active palette when the theme changes', () => { + const previousLevel = chalk.level; + chalk.level = 3; // force truecolor so palette differences surface as ANSI + try { + const component = createComponent(); + const titleOf = (): string => { + const line = component.render(100).find((l) => strip(l).includes('Agent Swarm')); + if (line === undefined) throw new Error('title line not found'); + return line; + }; + const before = titleOf(); + + currentTheme.setPalette(lightColors); + const after = titleOf(); + + // Same visible text, different ANSI colours (reads currentTheme live). + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + } + }); + it('renders blank padding around the block without a bottom divider', () => { const component = createComponent(); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 47d0836a9..929a02e37 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -19,7 +18,7 @@ describe('AssistantMessageComponent', () => { }); it('uses the stable status bullet without stealing content width', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('abcdef'); @@ -31,7 +30,7 @@ describe('AssistantMessageComponent', () => { it('renders unknown markdown fence languages as plain text without stderr noise', () => { const stderr = captureProcessWrite('stderr'); try { - const theme = createMarkdownTheme(darkColors); + const theme = createMarkdownTheme(); expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); expect(stderr.text()).not.toContain('Could not find the language'); } finally { @@ -40,7 +39,7 @@ describe('AssistantMessageComponent', () => { }); it('preserves literal hook result XML in normal assistant text', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('\n{}\n'); diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index 89def6690..7a81f6f27 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,30 +9,21 @@ function strip(text: string): string { describe('BackgroundAgentStatusComponent', () => { it('renders started/completed with the shared bullet and failed with a red x marker', () => { - const started = new BackgroundAgentStatusComponent( - { - phase: 'started', - headline: 'explore agent started in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const completed = new BackgroundAgentStatusComponent( - { - phase: 'completed', - headline: 'explore agent completed in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const failed = new BackgroundAgentStatusComponent( - { - phase: 'failed', - headline: 'explore agent failed in background', - detail: 'Explore project structure · boom', - }, - darkColors, - ); + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'explore agent completed in background', + detail: 'Explore project structure', + }); + const failed = new BackgroundAgentStatusComponent({ + phase: 'failed', + headline: 'explore agent failed in background', + detail: 'Explore project structure · boom', + }); const startedLines = started.render(120).map((line) => strip(line).trimEnd()); const completedLines = completed.render(120).map((line) => strip(line).trimEnd()); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 66435dcc8..7f1517629 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -11,9 +10,9 @@ function strip(lines: string[]): string { describe('buildGoalMarker', () => { it('builds lifecycle markers for paused / resumed / blocked', () => { - const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, darkColors, false); - const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, darkColors, false); - const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, darkColors, false); + const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, false); + const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, false); + const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, false); expect(strip(paused!.render(80))).toContain('Goal paused'); expect(strip(resumed!.render(80))).toContain('Goal resumed'); expect(strip(blocked!.render(80))).toContain('Goal blocked'); @@ -22,13 +21,11 @@ describe('buildGoalMarker', () => { it('renders user interruption pause and user resume as prominent markers', () => { const paused = buildGoalMarker( { kind: 'lifecycle', status: 'paused', reason: 'Paused after interruption' } as GoalChange, - darkColors, false, 'runtime', ); const resumed = buildGoalMarker( { kind: 'lifecycle', status: 'active' } as GoalChange, - darkColors, false, 'user', ); @@ -43,7 +40,6 @@ describe('buildGoalMarker', () => { it('does not repeat paused for runtime pause reasons', () => { const marker = buildGoalMarker( { kind: 'lifecycle', status: 'paused', reason: 'Paused after runtime error: socket hang up' } as GoalChange, - darkColors, false, 'runtime', ); @@ -54,13 +50,11 @@ describe('buildGoalMarker', () => { it('attributes model pause and resume markers to the agent', () => { const paused = buildGoalMarker( { kind: 'lifecycle', status: 'paused' } as GoalChange, - darkColors, false, 'model', ); const resumed = buildGoalMarker( { kind: 'lifecycle', status: 'active' } as GoalChange, - darkColors, false, 'model', ); @@ -71,14 +65,14 @@ describe('buildGoalMarker', () => { it('returns null for a completion change (it posts its own message)', () => { expect( - buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), + buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, false), ).toBeNull(); }); }); describe('GoalMarkerComponent', () => { it('hides the reason until expanded, with a ctrl+o hint', () => { - const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', darkColors, darkColors.warning); + const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', 'warning'); const collapsed = strip(marker.render(80)); expect(collapsed).toContain('Goal: no progress'); expect(collapsed).toContain('(ctrl+o)'); @@ -91,7 +85,7 @@ describe('GoalMarkerComponent', () => { }); it('renders a single line when there is no reason', () => { - const marker = new GoalMarkerComponent('Goal paused', undefined, darkColors, darkColors.textDim); + const marker = new GoalMarkerComponent('Goal paused', undefined, 'textDim'); expect(marker.render(80)).toHaveLength(1); expect(strip(marker.render(80))).not.toContain('(ctrl+o)'); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index e56bde8c2..0436e6681 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -44,7 +44,7 @@ function goal(overrides: Partial = {}): GoalSnapshot { } function lines(g: GoalSnapshot): string { - return strip(buildGoalReportLines({ colors: darkColors, goal: g })); + return strip(buildGoalReportLines(g)); } describe('buildGoalReportLines', () => { @@ -101,14 +101,14 @@ describe('buildGoalReportLines', () => { describe('GoalSetMessageComponent', () => { it('renders a marker-style lifecycle line without repeating the objective', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); // Leading blank line separates it from the line above. expect(rendered[0]).toBe(''); expect(strip(rendered)).toBe('\n● Goal set'); }); it('renders the marker and label in the primary accent', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); expect(rendered[1]).toBe( chalk.hex(darkColors.primary).bold(STATUS_BULLET) + @@ -119,7 +119,7 @@ describe('GoalSetMessageComponent', () => { describe('UpcomingGoalAddedMessageComponent', () => { it('renders the upcoming-goal confirmation like the goal-set lifecycle line', () => { - const rendered = new UpcomingGoalAddedMessageComponent(darkColors).render(80); + const rendered = new UpcomingGoalAddedMessageComponent().render(80); expect(strip(rendered)).toBe( '\n● Upcoming goal added. It will start after the current goal is complete.', @@ -135,7 +135,7 @@ describe('UpcomingGoalAddedMessageComponent', () => { describe('GoalStatusMessageComponent', () => { it('adds a blank line before the status box', () => { - const rendered = new GoalStatusMessageComponent(goal(), darkColors).render(80); + const rendered = new GoalStatusMessageComponent(goal()).render(80); expect(rendered[0]).toBe(''); expect(strip([rendered[1]!])).toContain('╭ Goal · active '); @@ -145,7 +145,7 @@ describe('GoalStatusMessageComponent', () => { describe('GoalCompletionMessageComponent', () => { it('renders the completion headline in green and keeps the stats line indented', () => { const message = '✓ Goal complete.\nWorked 1 turn over 2m28s, using 766.9k tokens.'; - const rendered = new GoalCompletionMessageComponent(message, darkColors).render(80); + const rendered = new GoalCompletionMessageComponent(message).render(80); expect(rendered[0]).toBe(''); expect(rendered[1]?.trimEnd()).toBe( diff --git a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts index b62c6df33..63b716208 100644 --- a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildMcpStatusReportLines } from '#/tui/components/messages/mcp-status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function strip(text: string): string { describe('buildMcpStatusReportLines', () => { it('folds a multi-line server error onto one row so the panel box stays intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ghidra', @@ -37,7 +35,6 @@ describe('buildMcpStatusReportLines', () => { it('trims and keeps a single-line error intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ida', diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index a7a9f05e3..0003ac01c 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -12,7 +11,6 @@ describe('NoticeComponent', () => { const component = new NoticeMessageComponent( 'Plan mode: ON', 'Plan will be created here: /tmp/plans/test-plan.md', - darkColors, ); const lines = component.render(120).map((line) => strip(line)); diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts index f59121c4b..128aa2cdd 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -4,7 +4,6 @@ import { ShellExecutionComponent, shellExecutionResultRenderer, } from '#/tui/components/messages/shell-execution'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -14,7 +13,6 @@ describe('ShellExecutionComponent', () => { it('renders shell command previews with prompt indentation', () => { const component = new ShellExecutionComponent({ command: 'printf hello\nprintf world', - colors: darkColors, showCommand: true, }); @@ -31,7 +29,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, }); const collapsedOutput = collapsed.render(100).map(strip).join('\n'); @@ -46,7 +43,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, expanded: true, }); @@ -60,7 +56,6 @@ describe('ShellExecutionComponent', () => { const cmd = Array.from({ length: 20 }, (_, i) => `step${String(i + 1)}`).join('\n'); const component = new ShellExecutionComponent({ command: cmd, - colors: darkColors, showCommand: true, commandPreviewLines: undefined, }); @@ -77,7 +72,6 @@ describe('ShellExecutionComponent', () => { output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -92,7 +86,6 @@ describe('ShellExecutionComponent', () => { output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -108,7 +101,6 @@ describe('ShellExecutionComponent', () => { output: 'x'.repeat(500), is_error: false, }, - colors: darkColors, }); const out = strip(component.render(20).join('\n')); @@ -132,7 +124,7 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: false, colors: darkColors }, + { expanded: false }, ); const rendered = components @@ -155,7 +147,7 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: true, colors: darkColors }, + { expanded: true }, ); const rendered = components diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 994fbf525..ca67aded7 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function strip(text: string): string { describe('status panel report lines', () => { it('formats runtime status, context, and managed usage without account or AGENTS.md rows', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: 'k2', workDir: '/tmp/project', @@ -72,7 +70,6 @@ describe('status panel report lines', () => { it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: '', workDir: '/tmp/project', diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 64c1b6955..7f1385ffe 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,7 +12,7 @@ const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'lin describe('ThinkingComponent', () => { it('shows the live spinner header before thinking content', () => { - const component = new ThinkingComponent('working it out', darkColors, true, 'live'); + const component = new ThinkingComponent('working it out', true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).toContain('⠋ thinking...'); @@ -23,7 +22,7 @@ describe('ThinkingComponent', () => { }); it('keeps live thinking height-limited to the tail', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).not.toContain('line1'); @@ -37,7 +36,7 @@ describe('ThinkingComponent', () => { it('animates the live spinner and stops on finalize', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const component = new ThinkingComponent('step', darkColors, true, 'live', { + const component = new ThinkingComponent('step', true, 'live', { requestRender, } as unknown as TUI); @@ -55,7 +54,7 @@ describe('ThinkingComponent', () => { }); it('finalizes in place into a collapsed preview', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); @@ -68,7 +67,7 @@ describe('ThinkingComponent', () => { }); it('expands and collapses after finalization', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); component.setExpanded(true); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 4d54cea6d..576947f3a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { darkColors } from '#/tui/theme/colors'; -import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -42,7 +41,6 @@ describe('ToolCallComponent', () => { output: 'content', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -63,7 +61,6 @@ describe('ToolCallComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -95,7 +92,6 @@ describe('ToolCallComponent', () => { output: reminderOutput, is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -121,7 +117,6 @@ describe('ToolCallComponent', () => { output: 'do not show', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -152,7 +147,6 @@ describe('ToolCallComponent', () => { output, is_error: false, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -175,7 +169,6 @@ describe('ToolCallComponent', () => { output: 'provider request failed', is_error: true, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -196,7 +189,6 @@ describe('ToolCallComponent', () => { output: 'first line\nnope', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -218,12 +210,11 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# File Plan\n\n1. Do the focused fix.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('Current plan'); - expect(out).toContain('# File Plan'); + expect(out).toContain('File Plan'); expect(out).toContain('1. Do the focused fix.'); expect(out).not.toContain('Plan saved to: /tmp/plan.md'); }); @@ -236,9 +227,7 @@ describe('ToolCallComponent', () => { args: {}, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. @@ -265,9 +254,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -276,6 +263,24 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('more lines'); }); + it('plan preview controls are no-ops for non-ExitPlanMode tool calls', () => { + const component = new ToolCallComponent( + { + id: 'call_bash_plan', + name: 'Bash', + args: { command: 'echo hi' }, + }, + undefined, + undefined, + ); + + component.setPlanInfo({ plan: 'should be ignored', path: '/etc/hosts' }); + + const out = strip(component.render(100).join('\n')); + expect(out).not.toContain('should be ignored'); + expect(out).not.toContain('plan:'); + }); + it('ctrl+o does not affect the full plan preview', () => { const longPlan = `# P\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; const component = new ToolCallComponent( @@ -285,9 +290,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); component.setExpanded(true); const out = strip(component.render(100).join('\n')); @@ -310,7 +313,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# Plan body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -334,7 +336,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -353,9 +354,7 @@ describe('ToolCallComponent', () => { output: 'User rejected the plan. Feedback:\n\nplease rethink step 2', is_error: false, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -376,9 +375,7 @@ describe('ToolCallComponent', () => { output: 'Plan rejected by user. Plan mode remains active.', is_error: true, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -407,7 +404,6 @@ describe('ToolCallComponent', () => { 'Do NOT edit files other than the plan file while plan mode is active.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -429,7 +425,6 @@ describe('ToolCallComponent', () => { output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -452,7 +447,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -478,7 +472,6 @@ describe('ToolCallComponent', () => { ].join('\n'), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -524,7 +517,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -546,7 +538,6 @@ describe('ToolCallComponent', () => { output: 'Goal budget set: 10 turns.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -571,7 +562,6 @@ describe('ToolCallComponent', () => { output: 'Goal budget set: 10 turns.', is_error: false, }, - darkColors, ); const out = component.render(100).join('\n'); @@ -594,7 +584,6 @@ describe('ToolCallComponent', () => { output: 'Goal marked blocked.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -621,7 +610,6 @@ describe('ToolCallComponent', () => { output: `Goal marked ${status}.`, is_error: false, }, - darkColors, ); const out = component.render(100).join('\n'); @@ -645,7 +633,6 @@ describe('ToolCallComponent', () => { output: '1\tfoo\n2\tbar\n3\tbaz', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -663,7 +650,6 @@ describe('ToolCallComponent', () => { args: { path: longPath }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -684,8 +670,6 @@ describe('ToolCallComponent', () => { output: '1\tcontent', is_error: false, }, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -704,8 +688,6 @@ describe('ToolCallComponent', () => { args: { path: '/tmp/proj-ab/src/main.ts' }, }, undefined, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -723,7 +705,6 @@ describe('ToolCallComponent', () => { args: { path: 'foo.ts' }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -741,7 +722,6 @@ describe('ToolCallComponent', () => { args: { description: 'explore project xxx' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ @@ -804,7 +784,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -844,7 +823,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -888,7 +866,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect wrapping' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_wrapped', @@ -925,7 +902,6 @@ describe('ToolCallComponent', () => { args: { description: 'long think' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_scroll', @@ -954,7 +930,6 @@ describe('ToolCallComponent', () => { args: { description: 'run bash' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_bash', @@ -995,7 +970,6 @@ describe('ToolCallComponent', () => { args: { description: 'mixed tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_mixed', @@ -1042,7 +1016,6 @@ describe('ToolCallComponent', () => { args: { description: 'check failure' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_failed', @@ -1090,7 +1063,6 @@ describe('ToolCallComponent', () => { }, }, spawnSuccessResult, - darkColors, ); component.onSubagentSpawned({ agentId: 'agent-0', @@ -1155,7 +1127,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent A', run_in_background: true }, }, undefined, - darkColors, ); component.setBackgroundTaskTerminalStatus('lost'); // Now the spawn-success result lands. @@ -1206,7 +1177,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent 1', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); // No spawn metadata was wired in — exactly the resume / backgrounded // case we are guarding against. @@ -1224,7 +1194,6 @@ describe('ToolCallComponent', () => { args: { description: 'X', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); component.setSubagentMeta('agent-explicit', 'coder'); expect(component.getSubagentAgentId()).toBe('agent-explicit'); @@ -1242,7 +1211,6 @@ describe('ToolCallComponent', () => { output: 'agent_id: agent-fake\nstatus: running', is_error: false, }, - darkColors, ); expect(component.getSubagentAgentId()).toBeUndefined(); }); @@ -1293,7 +1261,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1322,7 +1289,6 @@ describe('ToolCallComponent', () => { truncated: true, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1361,7 +1327,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1395,7 +1360,6 @@ describe('ToolCallComponent', () => { // No streamingArguments → finalized args; no result yet. }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); @@ -1417,7 +1381,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('line25'); @@ -1443,7 +1406,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{', }, undefined, - darkColors, ); const before = strip(component.render(100).join('\n')); expect(before).toContain('Using Write'); @@ -1470,7 +1432,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{"file_path":"foo.ts","content":"a\\nb', }, undefined, - darkColors, ); // While streaming, body is rendered live from streamingArguments. expect(strip(component.render(100).join('\n'))).toMatch(/^\s*1\s+a\s*$/m); @@ -1497,7 +1458,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: Date.now(), }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('Preparing changes'); expect(strip(component.render(100).join('\n'))).not.toMatch(/^\s*\d+\s+[+-]\s/m); @@ -1526,7 +1486,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); @@ -1553,7 +1512,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); ui.requestRender.mockClear(); @@ -1576,7 +1534,6 @@ describe('ToolCallComponent', () => { output: 'Wrote big.txt', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -1607,7 +1564,6 @@ describe('ToolCallComponent', () => { output: 'Wrote demo.abcxyz', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts index 5b54fe6a6..0c6e37d8d 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; -import { darkColors } from '#/tui/theme/colors'; + function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); @@ -12,7 +12,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent(['a', 'b', 'c', 'd', 'e'].join('\n'), { expanded: false, isError: false, - colors: darkColors, maxLines: 2, indent: 6, }); @@ -27,7 +26,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('x\ny\nz', { expanded: false, isError: false, - colors: darkColors, maxLines: 1, }); @@ -40,7 +38,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('a\nb\nc\nd', { expanded: false, isError: false, - colors: darkColors, maxLines: 2, indent: 4, expandHint: false, @@ -54,7 +51,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('a\nb\nc\nd', { expanded: true, isError: false, - colors: darkColors, maxLines: 2, indent: 4, }); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 5e3883ca9..fb47e3d14 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,8 +1,12 @@ import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -11,7 +15,6 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { const lines = buildUsageReportLines({ - colors: darkColors, sessionUsage: { byModel: { kimi: { @@ -46,7 +49,7 @@ describe('UsagePanelComponent', () => { }); it('wraps preformatted usage lines in a bordered panel', () => { - const component = new UsagePanelComponent(['Session usage'], darkColors.primary); + const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); expect(output[0]).toContain(' Usage '); @@ -55,7 +58,7 @@ describe('UsagePanelComponent', () => { it('truncates lines wider than the terminal so the panel never overflows', () => { const longLine = 'error: ' + 'x'.repeat(200); - const component = new UsagePanelComponent([longLine], darkColors.primary); + const component = new UsagePanelComponent(() => [longLine], 'primary'); const width = 60; const output = component.render(width); @@ -64,4 +67,20 @@ describe('UsagePanelComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it('rebuilds its body from the active palette on invalidate', () => { + // Emit the resolved palette value as visible text so the assertion holds + // regardless of chalk's colour level in the test environment. + const component = new UsagePanelComponent(() => [`text=${currentTheme.color('text')}`], 'primary'); + const bodyOf = (): string => { + const line = component.render(80).map(strip).find((l) => l.includes('text=')); + if (line === undefined) throw new Error('body line not found'); + return line; + }; + + expect(bodyOf()).toContain(darkColors.text); + currentTheme.setPalette(lightColors); + component.invalidate(); + expect(bodyOf()).toContain(lightColors.text); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index ab1d5752b..60029b40c 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -11,7 +11,6 @@ describe('UserMessageComponent', () => { it('renders video placeholders as plain text, not inline image escapes', () => { const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', - darkColors, [], ); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index bb846c7eb..a9cc544fe 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -35,7 +34,7 @@ function baseState(overrides: Partial = {}): AppState { describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); const [line1] = footer.render(120); expect(line1).toBeDefined(); expect(strip(line1!)).not.toMatch(/tasks? running/); @@ -43,7 +42,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the task badge alone when only bash tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -51,7 +50,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the agent badge alone when only agent tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 agent running\]/); @@ -59,7 +58,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders both badges side by side when both are non-zero', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); @@ -69,7 +68,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('pluralizes correctly across both badges', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -77,7 +76,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('updates badges live via setBackgroundCounts', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); @@ -87,7 +86,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('clamps negative counts to 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); const out = strip(footer.render(120)[0]!); expect(out).not.toMatch(/tasks? running/); @@ -95,7 +94,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('drops the badges when terminal is too narrow to fit them', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); // Extremely narrow width: footer primary content fills the line, so leftLine wins. const [line1] = footer.render(20); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index db34ee895..fb1cfd5fe 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -44,7 +44,7 @@ function baseState(overrides: Partial = {}): AppState { describe('FooterComponent — context NaN resilience', () => { it('NaN usage → renders 0.0% (never literal "NaN%")', () => { - const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); expect(out).toMatch(/context: 0\.0%/); @@ -53,7 +53,6 @@ describe('FooterComponent — context NaN resilience', () => { it('undefined-ish (coerced) usage → renders 0.0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), - darkColors, ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); @@ -61,13 +60,13 @@ describe('FooterComponent — context NaN resilience', () => { }); it('clamps ratios above 1.0 → renders 100.0%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 1.5 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); expect(out).toMatch(/context: 100\.0%/); }); it('ratio 0.427 → renders 42.7%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 0.427 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); expect(out).toMatch(/context: 42\.7%/); }); @@ -75,7 +74,6 @@ describe('FooterComponent — context NaN resilience', () => { it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { const fc = new FooterComponent( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), - darkColors, ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); @@ -85,7 +83,7 @@ describe('FooterComponent — context NaN resilience', () => { }); it('setState updates visible model and context values', () => { - const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 }), darkColors); + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); @@ -96,15 +94,15 @@ describe('FooterComponent — context NaN resilience', () => { }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true }), darkColors); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false }), darkColors); + const on = new FooterComponent(baseState({ model: 'k2', thinking: true })); + const off = new FooterComponent(baseState({ model: 'k2', thinking: false })); expect(strip(on.render(120)[0]!)).toContain('thinking'); expect(strip(off.render(120)[0]!)).not.toContain('thinking'); }); it('renders transient hints on the context line', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); @@ -134,7 +132,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const primaryIndex = out.indexOf(hexToSgr(darkColors.primary)); - const statusIndex = out.indexOf(hexToSgr(darkColors.status)); + const statusIndex = out.indexOf(hexToSgr(darkColors.textDim)); const badgeIndex = out.indexOf('[PR#6]'); expect(statusIndex).toBeGreaterThanOrEqual(0); expect(primaryIndex).toBeGreaterThanOrEqual(0); diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 59ec24331..d04c9c279 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; @@ -57,12 +56,12 @@ describe('FooterComponent — goal badge', () => { }); it('omits the badge when there is no goal', () => { - const footer = new FooterComponent(baseState({ goal: null }), darkColors); + const footer = new FooterComponent(baseState({ goal: null })); expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { - const footer = new FooterComponent(baseState({ goal: goal() }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal() })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('active'); @@ -78,7 +77,6 @@ describe('FooterComponent — goal badge', () => { const footer = new FooterComponent( baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('0s'); @@ -90,7 +88,7 @@ describe('FooterComponent — goal badge', () => { vi.useFakeTimers(); const onRefresh = vi.fn(); - new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), darkColors, onRefresh); + new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), onRefresh); vi.advanceTimersByTime(1_000); expect(onRefresh).toHaveBeenCalledTimes(1); @@ -99,30 +97,29 @@ describe('FooterComponent — goal badge', () => { it('shows used/limit turns only when a turn budget is set', () => { const footer = new FooterComponent( baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); }); it('shows a paused badge', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); expect(strip(footer.render(160)[0]!)).toContain('paused'); }); it('shows a blocked badge (resumable, still present)', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('blocked'); }); it('hides the badge for a completed goal', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); }); it('singularizes a single turn', () => { - const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('1 turn'); expect(out).not.toContain('1 turns'); diff --git a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts index d9692366a..52f94430b 100644 --- a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { KimiSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; -import { darkColors } from '#/tui/theme/colors'; function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { return { @@ -20,7 +19,6 @@ describe('HelpPanelComponent', () => { it('renders keyboard shortcuts + slash commands sections', () => { const panel = new HelpPanelComponent({ commands: [cmd('exit', 'Exit', ['quit', 'q'])], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -42,7 +40,6 @@ describe('HelpPanelComponent', () => { cmd('alpha', 'A'), cmd('mcp-config', 'M'), ], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -60,7 +57,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('\u001B'); // Esc @@ -71,7 +67,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('q'); @@ -83,7 +78,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, `Desc ${String(i)}`)); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); @@ -95,7 +89,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 6813a7137..dcf786129 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -15,7 +15,7 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } -const theme = createMarkdownTheme(darkColors); +const theme = createMarkdownTheme(); describe('PlanBoxComponent', () => { it('falls back to bare " plan " title when no path is provided', () => { @@ -77,7 +77,7 @@ describe('PlanBoxComponent', () => { it('skips the hyperlink for non-absolute paths but still shows the basename', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, 'relative/plan.md'); const top = box.render(60)[0]!; - expect(top).not.toContain(`${ESC}]8;`); + expect(top).not.toContain(`${ESC}];`); expect(strip(top)).toContain(' plan: plan.md '); }); @@ -97,5 +97,4 @@ describe('PlanBoxComponent', () => { expect(out).toContain('step 30'); expect(out).not.toContain('more lines'); }); - }); diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index 2382c0a60..04e3f1b88 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -5,7 +5,6 @@ import { selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,13 +12,13 @@ function strip(text: string): string { describe('TodoPanelComponent', () => { it('returns no lines when empty (so the layout slot collapses)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); expect(panel.render(80)).toEqual([]); expect(panel.isEmpty()).toBe(true); }); it('renders a Todo header + one row per entry', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'Investigate parser', status: 'done' }, { title: 'Add tests', status: 'in_progress' }, @@ -34,7 +33,7 @@ describe('TodoPanelComponent', () => { }); it('setTodos replaces the list (not appends)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'old', status: 'pending' }]); panel.setTodos([{ title: 'new', status: 'in_progress' }]); const out = strip(panel.render(80).join('\n')); @@ -43,7 +42,7 @@ describe('TodoPanelComponent', () => { }); it('clear() wipes the list and reverts to empty', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'x', status: 'pending' }]); panel.clear(); expect(panel.isEmpty()).toBe(true); @@ -51,7 +50,7 @@ describe('TodoPanelComponent', () => { }); it('defensive copy: external mutation does not leak into the panel', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); const source: TodoItem[] = [{ title: 'foo', status: 'pending' }]; panel.setTodos(source); source[0] = { title: 'hacked', status: 'done' }; @@ -61,7 +60,7 @@ describe('TodoPanelComponent', () => { }); it('renders all todos and no overflow footer when count <= 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'a', status: 'done' }, { title: 'b', status: 'in_progress' }, @@ -76,7 +75,7 @@ describe('TodoPanelComponent', () => { }); it('appends "+N more" footer when count > 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 't0', status: 'done' }, { title: 't1', status: 'in_progress' }, diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index 4abfdc606..68a5fc1d8 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { QueuePaneComponent } from '#/tui/components/panes/queue-pane'; -import { darkColors } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function stripAnsi(text: string): string { describe('QueuePaneComponent', () => { it('renders queued messages with the steer hint', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, @@ -29,7 +27,6 @@ describe('QueuePaneComponent', () => { it('renders compaction hint when waiting for compaction', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: true, isStreaming: false, canSteerImmediately: true, @@ -43,7 +40,6 @@ describe('QueuePaneComponent', () => { it('omits the steer hint when immediate steering is disabled', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: false, @@ -59,7 +55,6 @@ describe('QueuePaneComponent', () => { it('truncates long messages to a single line', () => { const longText = 'a'.repeat(200); const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, @@ -75,7 +70,6 @@ describe('QueuePaneComponent', () => { it('collapses multiline text into a single line', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 82bef3010..21c4f4b6c 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -118,4 +118,19 @@ command = " " upgrade: { autoInstall: false }, }); }); + + it('escapes special characters in a custom theme name so the TOML round-trips', async () => { + const theme = 'weird"name\\with-quote'; + await saveTuiConfig( + { + theme, + editorCommand: null, + notifications: DEFAULT_TUI_CONFIG.notifications, + upgrade: DEFAULT_TUI_CONFIG.upgrade, + }, + filePath, + ); + + expect((await loadTuiConfig(filePath)).theme).toBe(theme); + }); }); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 313da543a..4794d1ab4 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; vi.mock('#/tui/goal-queue-store', () => ({ @@ -54,7 +54,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], - theme: { colors: getColorPalette('dark') }, + theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 04bdfacd2..eb9dbd986 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -56,8 +56,7 @@ describe('createTUIState', () => { expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); expect(state.todoPanel).toBeDefined(); - expect(state.theme.colors).toBeDefined(); - expect(state.theme.markdownTheme).toBeDefined(); + expect(state.theme.palette).toBeDefined(); // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); diff --git a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts index 9373018f2..5406a2998 100644 --- a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts +++ b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts @@ -168,7 +168,7 @@ describe('installRainbowDance', () => { const dispose = installRainbowDance(requestRender); const host = { showStatus: vi.fn(), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); @@ -202,7 +202,7 @@ function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: strin setRainbowDance(rainbowDance); const host = { showStatus: (msg: string) => status.push(msg), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; return { host, calls, status }; } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 42200cfa0..0d638102b 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -106,7 +106,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 6b0caf0c9..7cc5e1996 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -64,7 +64,6 @@ const MIGRATION_PLAN: MigrationPlan = { function makeStartupInput( cliOptions: Partial = {}, tuiConfig: Partial = {}, - resolvedTheme: KimiTUIStartupInput["resolvedTheme"] = "dark", ): KimiTUIStartupInput { return { cliOptions: { @@ -88,7 +87,6 @@ function makeStartupInput( }, version: "0.0.0-test", workDir: "/tmp/proj-a", - resolvedTheme, }; } @@ -392,7 +390,7 @@ describe("KimiTUI startup", () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: "auto" }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -408,17 +406,14 @@ describe("KimiTUI startup", () => { expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("light"); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); @@ -437,7 +432,7 @@ describe("KimiTUI startup", () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: "auto" }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 306e80531..ef5b4b717 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -54,7 +54,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index d565e160e..9d630a26d 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -31,7 +31,6 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-signals', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 8c4879369..a7cbfe0df 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -63,7 +63,6 @@ function makeViewer(opts: { taskId: opts.taskInfo?.taskId ?? 'bash-aaaaaaaa', info: opts.taskInfo ?? info(), output: opts.output, - colors: darkColors, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 30, opts.columns ?? 120), @@ -219,7 +218,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(50), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -235,7 +233,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(200), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -252,7 +249,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: same, - colors: darkColors, onClose: () => {}, }); const after = strip(viewer.render(120).join('\n')); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index a27c495cf..3f0e95fa0 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -64,7 +64,6 @@ function makeProps(overrides: Partial = {}): TasksBrowserProp tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: darkColors, onSelect: vi.fn(), onToggleFilter: vi.fn(), onRefresh: vi.fn(), diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 055378376..1f1888223 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { TUIState } from "#/tui/kimi-tui"; -import { darkColors, lightColors, getColorPalette } from "#/tui/theme/colors"; -import { createThemeStyles } from "#/tui/theme/styles"; +import { darkColors, lightColors } from "#/tui/theme/colors"; +import { getBuiltInPalette } from "#/tui/theme"; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -169,29 +169,9 @@ describe('ColorPalette warning token', () => { }); it('resolves the correct palette by theme name', () => { - expect(getColorPalette('dark')).toBe(darkColors); - expect(getColorPalette('light')).toBe(lightColors); + expect(getBuiltInPalette('dark')).toBe(darkColors); + expect(getBuiltInPalette('light')).toBe(lightColors); }); }); -describe('ThemeStyles warning helper', () => { - it('wraps text and includes the input', () => { - const styles = createThemeStyles(darkColors); - const result = styles.warning('test'); - expect(result).toContain('test'); - }); - it('is a function that returns a string', () => { - const darkStyles = createThemeStyles(darkColors); - expect(typeof darkStyles.warning).toBe('function'); - expect(typeof darkStyles.warning('hello')).toBe('string'); - }); - - it('creates independent style sets per palette', () => { - const darkStyles = createThemeStyles(darkColors); - const lightStyles = createThemeStyles(lightColors); - expect(darkStyles.colors.warning).toBe(darkColors.warning); - expect(lightStyles.colors.warning).toBe(lightColors.warning); - expect(darkStyles.colors.warning).not.toBe(lightStyles.colors.warning); - }); -}); diff --git a/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts new file mode 100644 index 000000000..226901338 --- /dev/null +++ b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getCustomThemesDir, + listCustomThemes, + listCustomThemesSync, + loadCustomTheme, + loadCustomThemeMerged, +} from '#/tui/theme/custom-theme-loader'; +import { darkColors, lightColors } from '#/tui/theme'; + +let home: string; +const originalHome = process.env['KIMI_CODE_HOME']; + +beforeEach(() => { + home = join(tmpdir(), `kimi-themes-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(home, 'themes'), { recursive: true }); + process.env['KIMI_CODE_HOME'] = home; +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalHome; + } +}); + +function writeTheme(name: string, body: unknown): void { + writeFileSync(join(getCustomThemesDir(), `${name}.json`), JSON.stringify(body), 'utf-8'); +} + +describe('custom theme loader', () => { + it('excludes reserved built-in names from the listing', async () => { + writeTheme('dark', { name: 'dark', colors: {} }); + writeTheme('light', { name: 'light', colors: {} }); + writeTheme('auto', { name: 'auto', colors: {} }); + writeTheme('solarized', { name: 'solarized', colors: { primary: '#268bd2' } }); + + expect(await listCustomThemes()).toEqual(['solarized']); + expect(listCustomThemesSync()).toEqual(['solarized']); + }); + + it('filters invalid hex values without writing to the terminal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeTheme('mixed', { + name: 'mixed', + colors: { primary: '#268bd2', text: 'not-a-hex', accent: '#ff0000' }, + }); + + const loaded = await loadCustomTheme('mixed'); + expect(loaded).toEqual({ primary: '#268bd2', accent: '#ff0000' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('returns null for a missing theme file', async () => { + expect(await loadCustomTheme('does-not-exist')).toBeNull(); + }); + + it('falls back to the dark palette for unspecified tokens by default', async () => { + writeTheme('solar-dark', { name: 'solar-dark', colors: { primary: '#268bd2' } }); + const merged = await loadCustomThemeMerged('solar-dark'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(darkColors.text); + }); + + it('falls back to the light palette when base is "light"', async () => { + writeTheme('solar-light', { + name: 'solar-light', + base: 'light', + colors: { primary: '#268bd2' }, + }); + const merged = await loadCustomThemeMerged('solar-light'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(lightColors.text); + }); +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8a76e87d6..f35266ad5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -68,6 +68,7 @@ const config = withMermaid(defineConfig({ { text: 'Plugins', link: '/zh/customization/plugins' }, { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, { text: 'Hooks', link: '/zh/customization/hooks' }, + { text: '自定义主题', link: '/zh/customization/themes' }, ], }, ], @@ -144,6 +145,7 @@ const config = withMermaid(defineConfig({ { text: 'Plugins', link: '/en/customization/plugins' }, { text: 'Agents and Subagents', link: '/en/customization/agents' }, { text: 'Hooks', link: '/en/customization/hooks' }, + { text: 'Custom Themes', link: '/en/customization/themes' }, ], }, ], diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bd7cefa45..7cf622736 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -243,7 +243,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | -| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, or `light` | +| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -251,7 +251,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c ```toml # ~/.kimi-code/tui.toml -theme = "auto" # "auto" | "dark" | "light" +theme = "auto" # "auto" | "dark" | "light" | custom theme name [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/en/customization/themes.md b/docs/en/customization/themes.md new file mode 100644 index 000000000..af3ddf2db --- /dev/null +++ b/docs/en/customization/themes.md @@ -0,0 +1,111 @@ +# Custom Themes + +Kimi Code CLI can use a built-in color scheme or a custom JSON theme file. Custom files live in the themes directory and appear in `/theme` alongside the built-in choices. + +## Built-in color tokens + +Custom themes can override the tokens below. The `dark` and `light` columns show the built-in values; `auto` resolves to one of those palettes at startup, and falls back to `dark` when terminal background detection is unavailable. + +| Token | `dark` | `light` | What it controls | +| --- | --- | --- | --- | +| `primary` | `#4FA8FF` | `#1565C0` | The most-used color. Links, inline code, the selected item in nearly every dialog, the focused editor border, Plan/"running" badges, spinners | +| `accent` | `#5BC0BE` | `#00838F` | Secondary highlight. Approval `▶` prefix, device-code box, image placeholder, BTW / queue panes, registry import | +| `text` | `#E0E0E0` | `#1A1A1A` | Body text. Dialog bodies, todo titles, footer model label, Markdown headings, assistant/tool message bullets, list bullets | +| `textStrong` | `#F5F5F5` | `#1A1A1A` | Emphasized / bold text. Input dialogs, status messages | +| `textDim` | `#888888` | `#454545` | Secondary, dimmed text. Thinking, hints, descriptions, completed todos, Markdown quotes, footer status bar | +| `textMuted` | `#6B6B6B` | `#5F5F5F` | Faintest text. Counters, scroll info, descriptions, Markdown link URLs, code-block borders | +| `border` | `#5A5A5A` | `#737373` | Pane and editor borders, Markdown horizontal rule | +| `borderFocus` | `#E8A838` | `#92660A` | Focus / attention border, currently only the approval panel | +| `success` | `#4EC87E` | `#0E7A38` | Success state. `✓`, "enabled", completed | +| `warning` | `#E8A838` | `#92660A` | Warning state. auto/yolo badges, stale markers, Plan mode hint | +| `error` | `#E85454` | `#B91C1C` | Error state. Error messages, failed tool output | +| `diffAdded` | `#4EC87E` | `#0E7A38` | Diff added lines | +| `diffRemoved` | `#E85454` | `#B91C1C` | Diff removed lines | +| `diffAddedStrong` | `#7AD99B` | `#0E7A38` | Diff intra-line changed words, added and bold | +| `diffRemovedStrong` | `#F08585` | `#B91C1C` | Diff intra-line changed words, removed and bold | +| `diffGutter` | `#6B6B6B` | `#737373` | Diff line-number gutter | +| `diffMeta` | `#888888` | `#5F5F5F` | Diff meta / hunk headers | +| `roleUser` | `#FFCB6B` | `#9A4A00` | User message bullet and text, skill-activation name | + +## Use the custom-theme skill + +You do not need to write the JSON by hand. Run the built-in `/custom-theme [extra text]` skill command to enter the custom-theme workflow; the skill can choose colors, write the file under `~/.kimi-code/themes/`, validate the hex values, and tell you how to apply it. + +Example invocations: + +- `/custom-theme Create a warm dark theme with amber accents.` +- `/custom-theme Make a light theme based on Solarized, but keep errors easy to see.` +- `/custom-theme Tweak my ember theme so diffs have higher contrast.` + +After activation, the skill usually asks whether you want a light or dark base, what mood or palette you prefer, and whether you have exact colors to include. If you use it to edit an existing theme, make sure it reads and backs up the file before overwriting it. + +## Create a theme + +Add a `.json` file to the themes directory: + +- `~/.kimi-code/themes/` +- or `$KIMI_CODE_HOME/themes/` when the `KIMI_CODE_HOME` environment variable is set + +Create the directory if it does not exist. **The filename is the theme name**: `ember.json` appears in `/theme` as `Custom: ember`. + +A minimal theme only sets the colors you want to change; the rest fall back to the **base palette** (`dark` by default): + +```json +{ + "name": "ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } +} +``` + +Fields: + +- `name` (required): the theme identifier. +- `displayName` (optional): a human-readable name. +- `base` (optional): the built-in palette that unspecified tokens inherit — `"dark"` (default) or `"light"`. Set `"base": "light"` when you are building a **light** theme so the tokens you leave out stay readable on a light background (otherwise they fall back to the dark palette). +- `colors` (optional): the color tokens to override, each a 6-digit hex value (e.g. `#FE8019`). + +Use the token names from [Built-in color tokens](#built-in-color-tokens). Any token you omit falls back to the selected base palette, so partial themes are fine: + +```json +{ + "name": "just-blue", + "colors": { + "primary": "#3B82F6", + "roleUser": "#3B82F6" + } +} +``` + +## Select a theme + +Two ways: + +1. **The `/theme` command** (recommended): opens the theme picker, where custom themes appear as `Custom: `. The picker **re-scans the themes directory every time it opens**, so a theme file you just added shows up **without a restart**. +2. **`tui.toml`**: set `theme` to your theme name: + + ```toml + # ~/.kimi-code/tui.toml + theme = "ember" + ``` + +## What happens on errors + +Custom themes are designed to never get in your way: + +- **An invalid color value** (not `#` followed by 6 hex digits): that one entry is silently skipped and falls back to the selected base palette; the rest of the colors still apply. +- **An unrecognized token**: ignored, with no effect on other colors. +- **A missing custom theme file or malformed JSON**: silently falls back to the built-in `dark` palette. It does not retry `auto`. + +## Editing the active theme + +If you edit the theme file that is **currently active**, the change is not reloaded automatically. To apply the new colors: + +- run `/reload-tui` — it reloads `tui.toml` and re-applies the current theme (including re-reading the theme file); or +- switch to another theme in `/theme` and back. + +::: warning Note +Re-selecting the **same** theme in `/theme` does not reload it (you get a "Theme unchanged" message). To reload changes to the active theme, use one of the two methods above. +::: diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 953352120..56418f1da 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -123,7 +123,7 @@ For example, `/skill:code-style` loads the Skill named `code-style` and sends it For convenience, external Skill commands also support a shorthand form that omits the `skill:` prefix — `/` — as long as the name is not taken by a system slash command. That is, `/code-style` falls back to matching `/skill:code-style`. -Built-in Skills shipped with Kimi Code CLI, such as `mcp-config`, appear directly as `/` in the slash command panel for cases like configuring MCP servers and handling MCP OAuth login. +Built-in Skills shipped with Kimi Code CLI appear directly as `/` in the slash command panel. For example, `/mcp-config` helps configure MCP servers and handle MCP OAuth login, and `/custom-theme [extra text]` invokes the custom-theme workflow to create or edit a TUI theme. ::: info All Skill commands are only available in the idle state. `flow`-type Skills are also exposed via `/skill:` — there is no separate `/flow:` namespace. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fc19dc3c0..885840245 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -243,7 +243,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light` | +| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes)的名字 | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -251,7 +251,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml -theme = "auto" # "auto" | "dark" | "light" +theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR diff --git a/docs/zh/customization/themes.md b/docs/zh/customization/themes.md new file mode 100644 index 000000000..dc983e7b3 --- /dev/null +++ b/docs/zh/customization/themes.md @@ -0,0 +1,111 @@ +# 自定义主题 + +Kimi Code CLI 可以使用内置配色,也可以使用自定义 JSON 主题文件。自定义文件放在主题目录下,会和内置选项一起出现在 `/theme` 里。 + +## 内置颜色 token + +自定义主题可以覆盖下面这些 token。`dark` 和 `light` 两列展示内置值;`auto` 会在启动时解析为其中一个调色板,如果无法检测终端背景,则回退到 `dark`。 + +| Token | `dark` | `light` | 控制什么 | +| --- | --- | --- | --- | +| `primary` | `#4FA8FF` | `#1565C0` | 最常用色。链接、行内代码、几乎所有对话框的选中项、编辑器聚焦边框、Plan/运行中徽章、spinner | +| `accent` | `#5BC0BE` | `#00838F` | 次级强调。审批 `▶` 前缀、设备码框、图片占位、BTW/队列面板、注册表导入 | +| `text` | `#E0E0E0` | `#1A1A1A` | 正文。对话框正文、todo 标题、footer 模型名、Markdown 标题、助手/工具消息子弹头、列表符号 | +| `textStrong` | `#F5F5F5` | `#1A1A1A` | 加粗强调文字。输入类对话框、状态消息 | +| `textDim` | `#888888` | `#454545` | 次级、变暗文字。思考、提示、描述、已完成 todo、Markdown 引用、footer 状态栏 | +| `textMuted` | `#6B6B6B` | `#5F5F5F` | 最浅文字。计数、滚动信息、描述、Markdown 链接 URL、代码块边框 | +| `border` | `#5A5A5A` | `#737373` | 面板与编辑器的普通边框、Markdown 分隔线 | +| `borderFocus` | `#E8A838` | `#92660A` | 聚焦/注意边框,目前仅审批面板使用 | +| `success` | `#4EC87E` | `#0E7A38` | 成功态。`✓`、已启用、完成 | +| `warning` | `#E8A838` | `#92660A` | 警告态。auto/yolo 徽章、过期标记、Plan 模式提示 | +| `error` | `#E85454` | `#B91C1C` | 错误态。错误信息、失败的工具输出 | +| `diffAdded` | `#4EC87E` | `#0E7A38` | diff 新增行 | +| `diffRemoved` | `#E85454` | `#B91C1C` | diff 删除行 | +| `diffAddedStrong` | `#7AD99B` | `#0E7A38` | diff 行内改动的新增词(加粗高亮) | +| `diffRemovedStrong` | `#F08585` | `#B91C1C` | diff 行内改动的删除词(加粗高亮) | +| `diffGutter` | `#6B6B6B` | `#737373` | diff 行号槽 | +| `diffMeta` | `#888888` | `#5F5F5F` | diff 元信息 / hunk 头 | +| `roleUser` | `#FFCB6B` | `#9A4A00` | 用户消息的子弹头与文字、技能激活名 | + +## 使用 custom-theme skill + +你不需要手写 JSON。运行内置 `/custom-theme [附加文本]` skill 命令进入自定义主题流程;这个 skill 可以帮你选颜色,把文件写到 `~/.kimi-code/themes/`,校验十六进制色值,并告诉你如何应用。 + +调用示例: + +- `/custom-theme Create a warm dark theme with amber accents.` +- `/custom-theme Make a light theme based on Solarized, but keep errors easy to see.` +- `/custom-theme Tweak my ember theme so diffs have higher contrast.` + +激活后,skill 通常会先问你想用浅色还是深色基准、偏好的风格或调色板,以及是否有必须包含的精确颜色。如果你用它编辑已有主题,请确保它先读取并备份文件,再覆盖写入。 + +## 创建一个主题 + +在主题目录下新建一个 `.json` 文件即可。主题目录是: + +- `~/.kimi-code/themes/` +- 如果设置了 `KIMI_CODE_HOME` 环境变量,则是 `$KIMI_CODE_HOME/themes/` + +目录不存在就自己建一个。**文件名就是主题名**:`ember.json` 会在 `/theme` 里显示为 `Custom: ember`。 + +一个最小的主题只需要写你想改的颜色,其余自动沿用**基准调色板**(默认是 `dark`): + +```json +{ + "name": "ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } +} +``` + +字段说明: + +- `name`(必填):主题的标识名。 +- `displayName`(可选):人类可读的名字。 +- `base`(可选):未指定的 token 沿用哪个内置调色板——`"dark"`(默认)或 `"light"`。做**浅色**主题时设为 `"base": "light"`,这样你没写的 token 在浅色背景上仍然可读(否则会回退到 dark 调色板)。 +- `colors`(可选):要覆盖的颜色 token,值是 6 位十六进制色值(如 `#FE8019`)。 + +使用 [内置颜色 token](#内置颜色-token) 里的 token 名。没有写到的 token 会自动回退到所选基准调色板的对应值,所以你完全可以只覆盖一部分: + +```json +{ + "name": "just-blue", + "colors": { + "primary": "#3B82F6", + "roleUser": "#3B82F6" + } +} +``` + +## 选用主题 + +两种方式: + +1. **`/theme` 命令**(推荐):打开主题选择器,自定义主题会以 `Custom: <文件名>` 出现。选择器**每次打开都会重新扫描主题目录**,所以你新加的主题文件**无需重启**就能看到。 +2. **`tui.toml`**:把 `theme` 设成你的主题名: + + ```toml + # ~/.kimi-code/tui.toml + theme = "ember" + ``` + +## 出错时会怎样 + +自定义主题的设计原则是"尽量别打断你": + +- **某个色值不合法**(不是 `#` 加 6 位十六进制):静默跳过这一项,并回退到所选基准调色板,其余颜色照常生效。 +- **写了无法识别的 token**:忽略,不影响其它颜色。 +- **自定义主题文件不存在或 JSON 损坏**:静默回退到内置 `dark` 调色板,不会再尝试 `auto`。 + +## 编辑正在使用的主题 + +如果你修改的是**当前正在生效**的那个主题文件,改动不会自动重新加载。让新颜色生效有两种办法: + +- 运行 `/reload-tui`——它会重新读取 `tui.toml` 并重新应用当前主题(包括重新读取主题文件); +- 或者在 `/theme` 里先切到另一个主题,再切回来。 + +::: warning 注意 +在 `/theme` 里**重新选中同一个主题**不会触发重载(只会提示 “Theme unchanged”)。要重载已激活主题的改动,用上面两种办法之一。 +::: diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 3b7c247d2..6df38d216 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -121,7 +121,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` 为方便输入,外部 Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/`,前提是该名称未被系统斜杠命令占用——即 `/code-style` 会回退匹配到 `/skill:code-style`。 -Kimi Code CLI 随包内置的 Skill(例如 `mcp-config`)会直接以 `/` 形式出现在斜杠命令面板中,用于配置 MCP server 和处理 MCP OAuth 登录等场景。 +Kimi Code CLI 随包内置的 Skill 会直接以 `/` 形式出现在斜杠命令面板中。例如,`/mcp-config` 用于配置 MCP server 和处理 MCP OAuth 登录,`/custom-theme [附加文本]` 用于进入自定义主题流程,创建或编辑 TUI 主题。 ::: info 说明 所有 Skill 命令仅在空闲状态下可用。`flow` 类型的 Skill 同样通过 `/skill:` 暴露,没有独立的 `/flow:` 命名空间。 diff --git a/packages/agent-core/src/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md new file mode 100644 index 000000000..1f4fe9716 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/custom-theme.md @@ -0,0 +1,100 @@ +--- +name: custom-theme +description: Create or edit a kimi-code custom color theme — a JSON file under ~/.kimi-code/themes/ that recolors the TUI. Use when the user wants their own theme, asks for a specific palette or mood, or wants to tweak an existing custom theme's colors. +--- + +# Create a kimi-code custom theme (custom-theme) + +Help the user design, write, and apply a custom color theme for the kimi-code TUI. A theme is a single JSON file; the TUI ships with `dark`, `light`, and `auto`, and any file the user adds becomes selectable alongside them. + +## What a theme is + +- A theme lives at `~/.kimi-code/themes/.json` (or `$KIMI_CODE_HOME/themes/.json` when that variable is set). Create the `themes/` directory if it doesn't exist. +- **The filename is the theme name**: `ember.json` shows up in the `/theme` picker as `Custom: ember`. +- Shape: + + ```json + { + "name": "ember", + "displayName": "Ember", + "colors": { + "primary": "#83A598", + "accent": "#FE8019" + } + } + ``` + + - `name` (required), `displayName` (optional), `base` (optional: `"dark"` default, or `"light"`), `colors` (each value a 6-digit hex `#RRGGBB`). +- **Partial themes are fine**: any token you leave out falls back to the **base** palette (`dark` by default; set `"base": "light"` for a light theme), so you can recolor just a few tokens or all of them. + +## Source of truth: the docs token reference + +Before choosing colors, use **FetchURL** to fetch the official custom-theme docs as the authoritative list of tokens and what each controls: + +``` +https://moonshotai.github.io/kimi-code/en/customization/themes.html +``` + +Only set tokens from this set — unknown keys are silently ignored at load. If FetchURL is unavailable or the fetch fails, fall back to the embedded reference below (it mirrors the same tokens) and tell the user you're working from the built-in list rather than the live docs. + +## Color tokens (what each controls) + +| Token | Controls | +| --- | --- | +| `primary` | The most-used color: links, inline code, the selected item in nearly every dialog, the focused editor border, plan/"running" badges, spinners | +| `accent` | Secondary highlight: approval `▶` prefix, device-code box, image placeholder, BTW / queue panes, registry import | +| `text` | Body text: dialog bodies, todo titles, footer model label, Markdown headings, assistant/tool message bullets, list bullets | +| `textStrong` | Emphasized / bold text: input dialogs, status messages | +| `textDim` | Secondary, dimmed text (the most widely used dim shade): thinking, hints, descriptions, completed todos, Markdown quotes, footer status bar | +| `textMuted` | Faintest text: counters, scroll info, descriptions, Markdown link URLs, code-block borders | +| `border` | Pane and editor borders, Markdown horizontal rule | +| `borderFocus` | Focus / attention border (currently only the approval panel) | +| `success` | Success state: `✓`, "enabled", completed | +| `warning` | Warning state: auto/yolo badges, stale markers, plan-mode hint | +| `error` | Error state: error messages, failed tool output | +| `diffAdded` | Diff added lines | +| `diffRemoved` | Diff removed lines | +| `diffAddedStrong` | Diff intra-line changed words, added (bold) | +| `diffRemovedStrong` | Diff intra-line changed words, removed (bold) | +| `diffGutter` | Diff line-number gutter | +| `diffMeta` | Diff meta / hunk headers | +| `roleUser` | User message bullet and text, skill-activation name (the one role color with its own hue) | + +## Workflow + +1. **Ask the user what they want first — before choosing any colors.** Clarify, in one short exchange: + - **Light or dark?** A light theme (dark text on a light background) or a dark theme (light text on a dark background). This sets the whole direction, so settle it first. For a light theme, set `"base": "light"` so the tokens you leave out inherit the light palette instead of dark. + - **What style / mood?** e.g. warm vs cool, vivid vs muted, high vs low contrast, a named vibe ("nord", "solarized", "sunset"), or a base to start from (an existing theme, or `dark` / `light`). + - **Any specific colors?** Whether they have exact hex values to anchor on (a brand color, a preferred `primary`, etc.). + + Use **AskUserQuestion** for the discrete choices (light vs dark, a few style options); use a plain question for free-form input like specific hex values. Don't start picking colors until you at least know light-vs-dark and the rough style. +2. **Pick a starting point.** + - Tweaking an existing custom theme: **Read** `~/.kimi-code/themes/.json` first — never overwrite a theme you haven't read. + - Starting fresh: build a `colors` object from the token table. You can `ls ~/.kimi-code/themes/` and Read one of the user's existing themes as a reference for the format. +3. **Choose colors deliberately.** + - Every value is a 6-digit hex `#RRGGBB` (not 3-digit, not a named color). + - Keep contrast usable against the user's terminal background: don't let `text` / `textDim` sit too close to the background, and keep `success` / `warning` / `error` clearly distinguishable from each other. + - `primary` is the most-seen color (links, selection, focus) — make it readable and distinct from `text`. + - `roleUser` is the one role color meant to stand on its own — give it a distinct hue. +4. **Write the file** to `~/.kimi-code/themes/.json` with **Write** for a new theme (pick a short kebab-case filename). When editing an existing theme, prefer **Edit** on just the color(s) that change so the rest stays intact — and back it up first (see Don'ts). +5. **Validate.** Confirm the file is valid JSON and every `colors` value matches `^#[0-9a-fA-F]{6}$`. A quick check with **Bash**: + + ```bash + node -e 'const p=require("os").homedir()+"/.kimi-code/themes/.json";const c=(require(p).colors)||{};const bad=Object.entries(c).filter(([,v])=>!/^#[0-9a-fA-F]{6}$/.test(v));console.log(bad.length?["invalid:",...bad]:"all hex valid")' + ``` + + Invalid values are silently skipped at load (they fall back to the base palette; not fatal), but fix them so the theme renders as intended. +6. **Tell the user how to apply it** (next section). + +## Applying the theme + +- The `/theme` picker re-scans the themes directory every time it opens, so a newly added file shows up **without restarting** — tell the user to run `/theme` and choose `Custom: `. +- Or set it in `tui.toml`: `theme = ""`. +- **Editing the active theme**: changes to the theme that's *currently in use* are not auto-reloaded. Tell the user to run **`/reload-tui`** (or switch to another theme and back). Re-selecting the **same** theme in `/theme` is a no-op ("Theme unchanged"). + +## Don'ts + +- Don't invent token names — only use the documented set; unknown keys are silently ignored. +- Don't write 3-digit hex or named colors — use full `#RRGGBB`. +- Before overwriting an existing theme file, **read it and back it up** (e.g. `cp .json ".json.$(date +%Y%m%d-%H%M%S).bak"`) so the user can recover. +- Don't tell the user to restart the app to apply a theme — `/theme` or `/reload-tui` is enough. diff --git a/packages/agent-core/src/skill/builtin/custom-theme.ts b/packages/agent-core/src/skill/builtin/custom-theme.ts new file mode 100644 index 000000000..ca4edaf06 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/custom-theme.ts @@ -0,0 +1,23 @@ +import { parseSkillText } from '../parser'; +import type { SkillDefinition } from '../types'; +import CUSTOM_THEME_BODY from './custom-theme.md'; + +const PSEUDO_PATH = 'builtin://custom-theme'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/custom-theme.md', + skillDirName: 'custom-theme', + source: 'builtin', + text: CUSTOM_THEME_BODY, +}); + +export const CUSTOM_THEME_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + disableModelInvocation: true, + }, +}; diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index cf7fc0804..af06bb1e4 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -1,4 +1,5 @@ import type { SkillRegistry } from '../registry'; +import { CUSTOM_THEME_SKILL } from './custom-theme'; import { IMPORT_FROM_CC_CODEX_SKILL } from './import-from-cc-codex'; import { MCP_CONFIG_SKILL } from './mcp-config'; import { @@ -12,12 +13,14 @@ export function registerBuiltinSkills(registry: SkillRegistry): void { registry.registerBuiltinSkill(MCP_CONFIG_SKILL); registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL); registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL); + registry.registerBuiltinSkill(CUSTOM_THEME_SKILL); registry.registerBuiltinSkill(SUB_SKILL_PARENT); registry.registerBuiltinSkill(SUB_SKILL_REVIEW); registry.registerBuiltinSkill(SUB_SKILL_CONSOLIDATE); } export { + CUSTOM_THEME_SKILL, IMPORT_FROM_CC_CODEX_SKILL, MCP_CONFIG_SKILL, SUB_SKILL_CONSOLIDATE, diff --git a/packages/agent-core/test/skill/builtin-custom-theme.test.ts b/packages/agent-core/test/skill/builtin-custom-theme.test.ts new file mode 100644 index 000000000..473fdf5bf --- /dev/null +++ b/packages/agent-core/test/skill/builtin-custom-theme.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { CUSTOM_THEME_SKILL, SkillRegistry, registerBuiltinSkills } from '../../src/skill'; + +describe('builtin skill: custom-theme', () => { + it('has the expected identity and inline metadata', () => { + expect(CUSTOM_THEME_SKILL.name).toBe('custom-theme'); + expect(CUSTOM_THEME_SKILL.source).toBe('builtin'); + expect(CUSTOM_THEME_SKILL.description.length).toBeGreaterThan(0); + expect(CUSTOM_THEME_SKILL.metadata.type).toBe('inline'); + }); + + it('is user-triggered only and hidden from model invocation', () => { + expect(CUSTOM_THEME_SKILL.metadata.disableModelInvocation).toBe(true); + }); + + it('pins the docs token reference and points users at ~/.kimi-code/themes and /theme', () => { + const content = CUSTOM_THEME_SKILL.content; + expect(content).toContain('customization/themes.html'); + expect(content).toContain('FetchURL'); + expect(content).toContain('~/.kimi-code/themes'); + expect(content).toContain('/theme'); + // every documented token should be named so the model knows the full set + for (const token of [ + 'primary', + 'accent', + 'text', + 'textStrong', + 'textDim', + 'textMuted', + 'border', + 'borderFocus', + 'success', + 'warning', + 'error', + 'diffAdded', + 'diffRemoved', + 'diffAddedStrong', + 'diffRemovedStrong', + 'diffGutter', + 'diffMeta', + 'roleUser', + ]) { + expect(content).toContain(`\`${token}\``); + } + }); + + it('registers through registerBuiltinSkills but stays out of the model skill listing', () => { + const registry = new SkillRegistry(); + registerBuiltinSkills(registry); + + expect(registry.getSkill('custom-theme')).toBeDefined(); + expect( + registry.listInvocableSkills().some((skill) => skill.name === 'custom-theme'), + ).toBe(false); + expect(registry.getKimiSkillsDescription()).toContain('custom-theme'); + expect(registry.getModelSkillListing()).not.toContain('custom-theme'); + }); +}); From 0abde8662a531293fc8faa7cf9089c43ad8d6d76 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 19:21:46 +0800 Subject: [PATCH 004/476] fix(tui): clarify grouped subagent progress (#587) --- .changeset/active-agent-groups.md | 5 + .../tui/components/messages/agent-group.ts | 173 +++++++++++++---- .../src/tui/components/messages/tool-call.ts | 3 + .../components/messages/agent-group.test.ts | 176 ++++++++++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 4 + .../kimi-code/test/tui/terminal-theme.test.ts | 2 - docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- 8 files changed, 326 insertions(+), 41 deletions(-) create mode 100644 .changeset/active-agent-groups.md create mode 100644 apps/kimi-code/test/tui/components/messages/agent-group.test.ts diff --git a/.changeset/active-agent-groups.md b/.changeset/active-agent-groups.md new file mode 100644 index 000000000..25c3e4d47 --- /dev/null +++ b/.changeset/active-agent-groups.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Clarify grouped subagent progress with active status breakdowns and elapsed time. diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 47decf64e..ec8d32d2f 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -30,6 +30,16 @@ interface AgentEntry { readonly tc: ToolCallComponent; } +interface PhaseCounts { + readonly done: number; + readonly failed: number; + readonly backgrounded: number; + readonly running: number; + readonly waiting: number; + readonly starting: number; + readonly terminal: number; +} + export class AgentGroupComponent extends Container { private readonly entries: AgentEntry[] = []; private readonly headerText: Text; @@ -134,13 +144,12 @@ export class AgentGroupComponent extends Container { private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { const total = snapshots.length; - const done = snapshots.filter((s) => s.phase === 'done').length; - const failed = snapshots.filter((s) => s.phase === 'failed').length; - const finished = done + failed; - const allDone = finished === total; + const counts = countPhases(snapshots); + const allDone = counts.terminal === total; const bullet = allDone ? currentTheme.fg('success', STATUS_BULLET) : currentTheme.fg('text', STATUS_BULLET); + const elapsedSeconds = maxElapsedSeconds(snapshots); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -150,21 +159,16 @@ export class AgentGroupComponent extends Container { : `${String(total)} agents finished`; const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); - const tail = formatHeaderTail(totalTools, totalTokens); + const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds }); return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } - let headerText = `Running ${String(total)} agents`; - // Mixed status gets a breakdown so the current state is clear. - if (finished > 0) { - const running = total - finished; - const parts: string[] = []; - if (done > 0) parts.push(`${String(done)} done`); - if (failed > 0) parts.push(`${String(failed)} failed`); - if (running > 0) parts.push(`${String(running)} running`); - headerText = `Running ${String(total)} agents (${parts.join(', ')})`; - } - return `${bullet}${currentTheme.boldFg('primary', headerText)}`; + const parts = formatBreakdownParts(counts); + const headerText = parts.length > 0 + ? `Running ${String(total)} agents (${parts.join(', ')})` + : `Running ${String(total)} agents`; + const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { @@ -195,8 +199,7 @@ export class AgentGroupComponent extends Container { return; } // Running or not-yet-started agents show latest activity, with a fallback. - const fallback = snap.phase === 'queued' ? 'Queued…' : 'Initializing…'; - const activity = snap.latestActivity ?? fallback; + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } @@ -222,33 +225,129 @@ export class AgentGroupComponent extends Container { } } +function countPhases(snapshots: readonly ToolCallSubagentSnapshot[]): PhaseCounts { + let done = 0; + let failed = 0; + let backgrounded = 0; + let running = 0; + let waiting = 0; + let starting = 0; + + for (const snap of snapshots) { + switch (snap.phase) { + case 'done': + done += 1; + break; + case 'failed': + failed += 1; + break; + case 'backgrounded': + backgrounded += 1; + break; + case 'queued': + waiting += 1; + break; + case 'running': + running += 1; + break; + case 'spawning': + case undefined: + starting += 1; + break; + } + } + + return { + done, + failed, + backgrounded, + running, + waiting, + starting, + terminal: done + failed + backgrounded, + }; +} + +function formatBreakdownParts(counts: PhaseCounts): string[] { + const parts: string[] = []; + if (counts.done > 0) parts.push(`${String(counts.done)} done`); + if (counts.failed > 0) parts.push(`${String(counts.failed)} failed`); + if (counts.backgrounded > 0) parts.push(`${String(counts.backgrounded)} backgrounded`); + if (counts.running > 0) parts.push(`${String(counts.running)} running`); + if (counts.waiting > 0) parts.push(`${String(counts.waiting)} waiting`); + if (counts.starting > 0) parts.push(`${String(counts.starting)} starting`); + return parts; +} + function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = (text: string) => currentTheme.dim(text); - const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; - const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; - return dim(`${tools}${tokens}`); + const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); + if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); + return currentTheme.dim(` · ${parts.join(' · ')}`); } function formatLineTail(snap: ToolCallSubagentSnapshot): string { - const dim = (text: string) => currentTheme.dim(text); - if (snap.phase === 'done') { - return dim(' · ') + currentTheme.fg('success', '✓ Completed'); + const separator = currentTheme.dim(' · '); + switch (snap.phase) { + case 'done': + return separator + currentTheme.fg('success', '✓ Completed'); + case 'failed': + return separator + currentTheme.fg('error', '✗ Failed'); + case 'backgrounded': + return separator + currentTheme.dim('◐ backgrounded'); + case 'queued': + return separator + currentTheme.fg('primary', 'Waiting'); + case 'running': + return separator + currentTheme.fg('primary', 'Running'); + case 'spawning': + case undefined: + return separator + currentTheme.fg('primary', 'Starting'); } - if (snap.phase === 'failed') { - return dim(' · ') + currentTheme.fg('error', '✗ Failed'); - } - if (snap.phase === 'backgrounded') { - return dim(' · ◐ backgrounded'); - } - return ''; } -function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = (text: string) => currentTheme.dim(text); +function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): string { + switch (phase) { + case 'queued': + return 'Waiting to start…'; + case 'running': + return 'Still working…'; + case 'spawning': + case undefined: + return 'Starting…'; + case 'done': + case 'failed': + case 'backgrounded': + return ''; + } +} + +function formatHeaderTail(args: { + readonly toolCount: number; + readonly tokens: number; + readonly elapsedSeconds: number | undefined; +}): string { const parts: string[] = []; - if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); - if (tokens > 0) parts.push(formatTokens(tokens)); - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + if (args.toolCount > 0) parts.push(`${String(args.toolCount)} tool${args.toolCount === 1 ? '' : 's'}`); + if (args.tokens > 0) parts.push(formatTokens(args.tokens)); + if (args.elapsedSeconds !== undefined) parts.push(formatElapsed(args.elapsedSeconds)); + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; +} + +function maxElapsedSeconds(snapshots: readonly ToolCallSubagentSnapshot[]): number | undefined { + let max: number | undefined; + for (const snap of snapshots) { + const elapsed = snap.elapsedSeconds; + if (elapsed === undefined) continue; + max = max === undefined ? elapsed : Math.max(max, elapsed); + } + return max; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; } function formatTokens(n: number): string { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 3c6bfe080..f9f5db738 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -87,6 +87,7 @@ export interface ToolCallSubagentSnapshot { readonly agentName: string | undefined; readonly phase: SubagentPhase | undefined; readonly toolCount: number; + readonly elapsedSeconds: number | undefined; readonly tokens: number; readonly isError: boolean; readonly errorText: string | undefined; @@ -777,6 +778,7 @@ export class ToolCallComponent extends Container { agentName: this.subagentAgentName, phase: derivedPhase, toolCount: finished, + elapsedSeconds: this.getSubagentElapsedSeconds(), tokens, isError: derivedPhase === 'failed', errorText, @@ -899,6 +901,7 @@ export class ToolCallComponent extends Container { } this.headerText.setText(this.buildHeader()); this.invalidate(); + this.notifySnapshotChange(); this.ui?.requestRender(); }, SUBAGENT_ELAPSED_INTERVAL_MS); } diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts new file mode 100644 index 000000000..5018275cc --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -0,0 +1,176 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +function strip(text: string): string { + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +function stubTui(): TUI { + return { + terminal: { rows: 40 }, + requestRender: vi.fn(), + } as unknown as TUI; +} + +function renderText(component: AgentGroupComponent, width = 120): string { + return strip(component.render(width).join('\n')); +} + +function createAgent( + id: string, + description: string, + agentName: string, + ui: TUI, +): ToolCallComponent { + const component = new ToolCallComponent( + { + id, + name: 'Agent', + args: { description }, + }, + undefined, + ui, + ); + component.onSubagentSpawned({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); + return component; +} + +function startAgent(component: ToolCallComponent, id: string, agentName: string): void { + component.onSubagentStarted({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); +} + +describe('AgentGroupComponent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows explicit active breakdown, row state, and waiting fallback', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + running.appendSubToolCall({ + id: 'sub_call_agent_1:read', + name: 'Read', + args: { path: 'src/a.ts' }, + }); + + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + expect(output).toContain('explore · inspect project · 0 tools · 0s · Running'); + expect(output).toContain('Using Read (src/a.ts)'); + expect(output).toContain('coder · write tests · 0 tools · 0s · Waiting'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('uses still-working fallback for running agents without recent activity', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Still working…'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('refreshes grouped elapsed time from child subagent timers', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + vi.mocked(ui.requestRender).mockClear(); + + vi.advanceTimersByTime(1_200); + + expect(ui.requestRender).toHaveBeenCalled(); + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 1s'); + expect(renderText(group)).toContain('explore · inspect project · 0 tools · 1s · Running'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('keeps terminal rows explicit while mixed agents are still running', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const done = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const running = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(done, 'call_agent_1', 'explore'); + startAgent(running, 'call_agent_2', 'coder'); + group.attach('call_agent_1', done); + group.attach('call_agent_2', running); + + vi.setSystemTime(12_000); + done.onSubagentCompleted({ resultSummary: 'done' }); + + const mixed = renderText(group); + expect(mixed).toContain('Running 2 agents (1 done, 1 running) · 12s'); + expect(mixed).toContain('explore · inspect project · 0 tools · 12s · ✓ Completed'); + expect(mixed).toContain('coder · write tests · 0 tools · 12s · Running'); + + vi.setSystemTime(15_000); + running.onSubagentFailed({ error: 'review failed' }); + + const terminal = renderText(group); + expect(terminal).toContain('2 agents finished · 15s'); + expect(terminal).toContain('✗ Failed'); + expect(terminal).toContain('Error: review failed'); + expect(terminal).not.toContain('Still working…'); + + group.dispose(); + done.dispose(); + running.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index ef5b4b717..85ce19db9 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -573,6 +573,10 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); + const output = stripAnsi((group as AgentGroupComponent).render(120).join('\n')); + expect(output).toContain('2 agents finished'); + expect(output).not.toContain('Still working…'); + expect(output).not.toContain('Waiting to start…'); expect(driver.streamingUI.hasPendingAgentGroup()).toBe(false); expect(driver.streamingUI.getToolComponent('call_agent_1')).toBeUndefined(); expect(driver.streamingUI.getToolComponent('call_agent_2')).toBeUndefined(); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 1f1888223..3159f8025 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -173,5 +173,3 @@ describe('ColorPalette warning token', () => { expect(getBuiltInPalette('light')).toBe(lightColors); }); }); - - diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index c354bc384..0856ff19e 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,7 +89,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. **`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 0747a5e1a..151ee273c 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,7 +89,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 **`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。 From f2863af267b2e7d5ff5b99ff80c95c379a5b0272 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 20:06:07 +0800 Subject: [PATCH 005/476] fix: keep login fallback prompt visible (#594) --- .changeset/login-browser-fallback.md | 5 +++ apps/kimi-code/src/cli/sub/login-flow.ts | 22 +++++++------ apps/kimi-code/test/cli/login.test.ts | 40 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 .changeset/login-browser-fallback.md diff --git a/.changeset/login-browser-fallback.md b/.changeset/login-browser-fallback.md new file mode 100644 index 000000000..cc27499f4 --- /dev/null +++ b/.changeset/login-browser-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix device login to keep the URL and code visible when the browser cannot be opened. diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts index 3c1a123d4..005dc1729 100644 --- a/apps/kimi-code/src/cli/sub/login-flow.ts +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -17,18 +17,17 @@ export async function runLoginFlow(): Promise { uiMode: 'cli', }); const controller = new AbortController(); - process.once('SIGINT', () => controller.abort()); + process.once('SIGINT', () => { + controller.abort(); + }); try { const result = await harness.auth.login(undefined, { signal: controller.signal, onDeviceCode: (data) => { const url = data.verificationUriComplete || data.verificationUri; - // Best-effort: try to open the user's default browser at the - // pre-baked URL (which already embeds the user code). Print the - // URL + code as a fallback for headless boxes / when openUrl - // silently fails (it `execFile`s `open`/`xdg-open`/`cmd start` - // with no error handling — see `utils/open-url.ts`). - openUrl(url); + // Print the manual fallback before attempting to open the user's + // browser so headless/browser-opener failures never hide the URL + // and code needed to complete login. process.stderr.write( [ '', @@ -43,15 +42,20 @@ export async function runLoginFlow(): Promise { .filter((line): line is string => line !== undefined) .join('\n'), ); + try { + openUrl(url); + } catch { + // Best effort only: the manual fallback has already been printed. + } }, }); process.stderr.write(`Logged in to ${result.providerName}.\n`); process.exit(0); - } catch (err) { + } catch (error) { if (controller.signal.aborted) { process.stderr.write('Login cancelled.\n'); } else { - const message = err instanceof Error ? err.message : String(err); + const message = error instanceof Error ? error.message : String(error); process.stderr.write(`Login failed: ${message}\n`); } process.exit(1); diff --git a/apps/kimi-code/test/cli/login.test.ts b/apps/kimi-code/test/cli/login.test.ts index 6a8a12b46..6644c7f21 100644 --- a/apps/kimi-code/test/cli/login.test.ts +++ b/apps/kimi-code/test/cli/login.test.ts @@ -122,6 +122,46 @@ describe('kimi login', () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it('still prints device code prompt when opening the browser fails', async () => { + vi.mocked(openUrl).mockImplementation(() => { + throw new Error('no browser'); + }); + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'kimi-code', ok: true }; + }, + ); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, + ); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + it('exits 1 when auth.login throws', async () => { mockLogin.mockRejectedValue(new Error('boom')); From e48234af576e41e630736450c66b690226707bc3 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 9 Jun 2026 20:20:44 +0800 Subject: [PATCH 006/476] fix: avoid Windows command shim launches (#591) --- .changeset/steady-windows-bins.md | 6 ++++++ apps/kimi-code/scripts/dev.mjs | 8 +++++--- packages/node-sdk/scripts/build-dts.mjs | 16 +++++++++++----- 3 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 .changeset/steady-windows-bins.md diff --git a/.changeset/steady-windows-bins.md b/.changeset/steady-windows-bins.md new file mode 100644 index 000000000..b4923ec44 --- /dev/null +++ b/.changeset/steady-windows-bins.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix Windows builds and development launches that could fail when package binaries resolve to command shims. diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 5bf5d460a..0e6be4cf2 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs'; +const require = createRequire(import.meta.url); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const APP_ROOT = resolve(SCRIPT_DIR, '..'); const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; @@ -18,12 +20,12 @@ if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length == console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); } -const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; +const tsxCli = require.resolve('tsx/cli'); const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( - tsxBin, - ['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], + process.execPath, + [tsxCli, '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], { cwd: APP_ROOT, env, diff --git a/packages/node-sdk/scripts/build-dts.mjs b/packages/node-sdk/scripts/build-dts.mjs index 2c90f4461..b25ac114b 100644 --- a/packages/node-sdk/scripts/build-dts.mjs +++ b/packages/node-sdk/scripts/build-dts.mjs @@ -1,12 +1,16 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import path from 'node:path'; +const require = createRequire(import.meta.url); const packageRoot = path.resolve(import.meta.dirname, '..'); const tempDir = path.join(packageRoot, '.tmp-api-extractor'); const dtsRoot = path.join(tempDir, 'dts'); const providerClientShimPath = path.join(dtsRoot, 'provider-clients.d.ts'); +const tscBinPath = packageBinPath('typescript', 'bin/tsc'); +const apiExtractorBinPath = packageBinPath('@microsoft/api-extractor', 'bin/api-extractor'); const packageDirs = new Set(['agent-core', 'kaos', 'kosong', 'node-sdk', 'oauth']); const workspacePackages = new Map([ @@ -18,19 +22,21 @@ const workspacePackages = new Map([ try { await rm(tempDir, { recursive: true, force: true }); - await run('tsc', ['-p', 'tsconfig.dts.json']); + await run('tsc', tscBinPath, ['-p', 'tsconfig.dts.json']); await writeProviderClientShim(); await rewriteWorkspaceSpecifiers(); - await run('api-extractor', ['run', '--local']); + await run('api-extractor', apiExtractorBinPath, ['run', '--local']); } finally { await rm(tempDir, { recursive: true, force: true }); } -function run(command, args) { - const executable = process.platform === 'win32' ? `${command}.cmd` : command; +function packageBinPath(packageName, binPath) { + return path.join(path.dirname(require.resolve(`${packageName}/package.json`)), binPath); +} +function run(command, binPath, args) { return new Promise((resolve, reject) => { - const child = spawn(executable, args, { + const child = spawn(process.execPath, [binPath, ...args], { cwd: packageRoot, stdio: 'inherit', }); From 46f909d694b5d0b4bd74ed891c061949cc678e26 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 20:39:33 +0800 Subject: [PATCH 007/476] docs: add built-in skill commands section to slash-commands and skills pages (#596) --- docs/en/customization/skills.md | 2 +- docs/en/reference/slash-commands.md | 14 ++++++++++++++ docs/zh/customization/skills.md | 2 +- docs/zh/reference/slash-commands.md | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index 3bb35fb54..70b36e1e8 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -81,7 +81,7 @@ The Kimi-specific user Skill directory moves with `KIMI_CODE_HOME`, so isolated extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` -**Built-in Skills** are distributed with the CLI and have the lowest priority. +**Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. ## Invoking a Skill diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 56418f1da..46e60d78f 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -111,6 +111,20 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | --- | --- | --- | --- | | `/exit` | `/quit`, `/q` | Exit Kimi Code CLI | No | +## Built-in skill commands + +Kimi Code CLI ships with a set of built-in Skills that appear directly as `/` slash commands. Unlike external Skills, they do not require the `skill:` prefix and are available out of the box. + +| Command | Description | +| --- | --- | +| `/mcp-config` | Configure MCP servers and handle MCP OAuth login. See [MCP](../customization/mcp.md) | +| `/custom-theme []` | Create or edit a custom TUI color theme. See [Themes](../customization/themes.md) | +| `/update-config` | Inspect or edit `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update) | +| `/import-from-cc-codex` | Import Claude Code and Codex instructions, skills, and MCP settings into Kimi Code | +| `/sub-skill` | Discover and reorganize the local skill inventory into hierarchical sub-skill bundles. Includes `/sub-skill.review` (read-only proposal) and `/sub-skill.consolidate` (apply the reorganization) | + +All built-in Skill commands are only available in the idle state. + ## Skill Dynamic Commands Activated external Skills are automatically registered as slash commands with the `skill:` namespace prefix: diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index c26d9a1b2..0ee0ce444 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -81,7 +81,7 @@ Kimi 专属用户级 Skill 目录会随 `KIMI_CODE_HOME` 移动,因此隔离 extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` -**内置 Skills** 随 CLI 一起分发,优先级最低。 +**内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。 ## 调用 Skill diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 6df38d216..8033ca00b 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -109,6 +109,20 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` | --- | --- | --- | --- | | `/exit` | `/quit`、`/q` | 退出 Kimi Code CLI | 否 | +## 内置 Skill 命令 + +Kimi Code CLI 随包内置了一组 Skill,直接以 `/` 形式出现在斜杠命令面板中。与外部 Skill 不同,它们不需要 `skill:` 前缀,开箱即用。 + +| 命令 | 说明 | +| --- | --- | +| `/mcp-config` | 配置 MCP server 并处理 MCP OAuth 登录。详见 [MCP](../customization/mcp.md) | +| `/custom-theme []` | 创建或编辑自定义 TUI 配色主题。详见 [主题](../customization/themes.md) | +| `/update-config` | 查看或编辑 `config.toml`(模型、供应商、权限、hooks)和 `tui.toml`(主题、编辑器、通知、自动更新) | +| `/import-from-cc-codex` | 从 Claude Code 和 Codex 导入 instructions、skills 和 MCP 设置 | +| `/sub-skill` | 发现并将本地 skill 库存重组为分层子 skill 包。包含 `/sub-skill.review`(只读提案)和 `/sub-skill.consolidate`(执行重组) | + +所有内置 Skill 命令仅在空闲状态下可用。 + ## Skill 动态命令 已激活的外部 Skill 会自动注册为斜杠命令,并以 `skill:` 作为命名空间前缀: From 40506f49d689aaf3e920c6bc9ae2b91219ee3f7f Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 9 Jun 2026 21:28:36 +0800 Subject: [PATCH 008/476] feat(tui): show available plugin updates in the marketplace (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed plugins whose marketplace version is newer than the local version now render an `update ` badge and update in place on Enter; up-to-date plugins show `installed · v`. Dev-server and CDN-build marketplace generation now stamp each entry's version from the plugin manifest so the advertised "latest" stays accurate. Adds a pure computeUpdateStatus() (semver, no spurious downgrades) with tests. Co-authored-by: qer --- .changeset/plugin-marketplace-update-badge.md | 5 ++ .../scripts/build-plugin-marketplace-cdn.mjs | 10 +++- .../scripts/dev-plugin-marketplace-server.mjs | 7 ++- .../scripts/plugin-manifest-version.mjs | 38 ++++++++++++++ apps/kimi-code/src/tui/commands/plugins.ts | 4 +- .../components/dialogs/plugins-selector.ts | 32 ++++++++---- .../kimi-code/src/utils/plugin-marketplace.ts | 32 ++++++++++++ .../dialogs/plugins-selector.test.ts | 52 +++++++++++++++++-- .../test/utils/plugin-marketplace.test.ts | 50 +++++++++++++++++- docs/en/customization/plugins.md | 2 + docs/zh/customization/plugins.md | 2 + 11 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 .changeset/plugin-marketplace-update-badge.md create mode 100644 apps/kimi-code/scripts/plugin-manifest-version.mjs diff --git a/.changeset/plugin-marketplace-update-badge.md b/.changeset/plugin-marketplace-update-badge.md new file mode 100644 index 000000000..0364658b5 --- /dev/null +++ b/.changeset/plugin-marketplace-update-badge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index 59d05fa86..eaa8d331f 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -6,6 +6,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -46,7 +48,13 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { continue; } const result = await materializeEntrySource(entry.source, pluginsRoot, outDir); - plugins.push({ ...entry, source: result.source }); + let stamped = { ...entry, source: result.source }; + if (isLocalRelativeSource(entry.source)) { + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source)); + if (version !== undefined) stamped = { ...stamped, version }; + } + plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); } diff --git a/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs index f65b4fe8a..2f7226737 100644 --- a/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs +++ b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs @@ -7,6 +7,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -109,7 +111,10 @@ async function rewriteMarketplaceJson(raw, pluginsRoot) { if (!isLocalRelativeSource(entry.source)) return entry; const sourcePath = resolveInsideRoot(pluginsRoot, entry.source); if (!(await isDirectory(sourcePath))) return entry; - return { ...entry, source: withZipExtension(entry.source) }; + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(sourcePath); + const withVersion = version !== undefined ? { ...entry, version } : entry; + return { ...withVersion, source: withZipExtension(withVersion.source) }; }), ); diff --git a/apps/kimi-code/scripts/plugin-manifest-version.mjs b/apps/kimi-code/scripts/plugin-manifest-version.mjs new file mode 100644 index 000000000..1fd768af5 --- /dev/null +++ b/apps/kimi-code/scripts/plugin-manifest-version.mjs @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// Read a local plugin directory's declared version from its manifest, mirroring +// the plugin loader's precedence (packages/agent-core/src/plugin/manifest.ts): +// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json` +// is only consulted when the root manifest is absent. Returns undefined when no +// manifest is present or the chosen manifest has no version — callers then leave +// the marketplace entry's existing version untouched. +export async function readPluginManifestVersion(pluginDir) { + for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) { + const raw = await readFileOrUndefined(resolve(pluginDir, rel)); + if (raw === undefined) continue; // manifest absent — fall back to the next candidate + return versionFromManifest(raw); // the chosen manifest wins, even if it has no version + } + return undefined; +} + +async function readFileOrUndefined(file) { + try { + return await readFile(file, 'utf8'); + } catch { + return undefined; + } +} + +function versionFromManifest(raw) { + try { + const parsed = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') { + const trimmed = parsed.version.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + } catch { + return undefined; + } + return undefined; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index bbcd883a0..cbfed5dcb 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -178,7 +178,9 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri host.mountEditorReplacement( new PluginMarketplaceSelectorComponent({ entries: marketplace.plugins, - installedIds: new Set(installed.map((plugin) => plugin.id)), + installed: new Map( + installed.map((plugin): [string, string | undefined] => [plugin.id, plugin.version]), + ), source: marketplace.source, onSelect: (selection) => { // Every marketplace action re-mounts a picker, so let the handler do diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index daf1155a9..3a4cce8d1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -12,7 +12,7 @@ import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; -import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { ChoicePickerComponent } from './choice-picker'; @@ -184,7 +184,7 @@ export type PluginMarketplaceSelection = export interface PluginMarketplaceSelectorOptions { readonly entries: readonly PluginMarketplaceEntry[]; - readonly installedIds: ReadonlySet; + readonly installed: ReadonlyMap; readonly source: string; readonly onSelect: (selection: PluginMarketplaceSelection) => void; readonly onCancel: () => void; @@ -200,7 +200,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc constructor(opts: PluginMarketplaceSelectorOptions) { super(); this.opts = opts; - this.items = buildMarketplaceItems(opts.entries, opts.installedIds); + this.items = buildMarketplaceItems(opts.entries, opts.installed); } handleInput(data: string): void { @@ -502,13 +502,13 @@ function overviewItemPluginId(item: PluginsOverviewItem): string | undefined { function buildMarketplaceItems( entries: readonly PluginMarketplaceEntry[], - installedIds: ReadonlySet, + installed: ReadonlyMap, ): PluginsOverviewItem[] { const items: PluginsOverviewItem[] = entries.map((entry) => ({ value: entry.id, kind: 'plugin', label: entry.displayName, - status: installedIds.has(entry.id) ? 'installed' : installStatus(entry), + status: marketplaceItemStatus(entry, installed), description: marketplaceEntryDescription(entry), })); items.push({ @@ -556,13 +556,13 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined { function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { const tier = marketplaceTierLabel(entry.tier); const description = entry.description ?? tier; - const version = entry.version !== undefined ? ` · v${entry.version}` : ''; const keywords = entry.keywords !== undefined && entry.keywords.length > 0 ? ` · ${entry.keywords.join(', ')}` : ''; const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; - return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; + // The version now lives in the status badge, so it is omitted here to avoid duplication. + return `${description} · id ${entry.id}${tierSuffix}${keywords}`; } function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { @@ -571,8 +571,19 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { return 'Plugin'; } -function installStatus(entry: PluginMarketplaceEntry): string { - return entry.version === undefined ? 'install' : `install v${entry.version}`; +function marketplaceItemStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap, +): string { + const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return entry.version === undefined ? 'install' : `install v${entry.version}`; + } } function sectionLabel(label: string): string { @@ -583,7 +594,8 @@ function statusStyle( item: PluginsOverviewItem, ): (text: string) => string { if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); - if (item.status === 'enabled' || item.status === 'installed') return (text) => currentTheme.fg('success', text); + if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text); + if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text); if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index d11748a55..5553e94c1 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -3,6 +3,8 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gt, valid } from 'semver'; + import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, @@ -29,6 +31,36 @@ export interface PluginMarketplace { readonly plugins: readonly PluginMarketplaceEntry[]; } +export type PluginUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +/** + * Compare a marketplace entry's (latest) version against the locally installed + * version. Only reports `update` when both are valid semver and latest > local, + * so a stale or non-semver version never produces a spurious or downgrading prompt. + */ +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): PluginUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + // Report only the actual installed version. When it is unknown, don't borrow the + // marketplace version — that would falsely claim "up to date" and hide future updates. + return { kind: 'up-to-date', version: local }; +} + interface MarketplaceLocation { readonly raw: string; readonly kind: 'remote' | 'local'; diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 34d7358e7..79d872cd3 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -171,7 +171,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -182,7 +182,7 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Marketplace (1)'); expect(out).toContain('? Superpowers install v5.1.0'); expect(out).toContain( - `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, + `Workflow skills ${MID} id superpowers ${MID} Curated plugin ${MID} workflow`, ); expect(out).toContain('Enter install/update'); expect(out).toContain('Actions'); @@ -209,7 +209,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -238,7 +238,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect: vi.fn(), onCancel, @@ -258,7 +258,7 @@ describe('plugins selector dialogs', () => { source: 'https://example.com/superpowers.zip', }, ], - installedIds: new Set(['superpowers']), + installed: new Map([['superpowers', undefined]]), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -275,6 +275,48 @@ describe('plugins selector dialogs', () => { }); }); + it('shows an update badge when the installed version is older than the marketplace', () => { + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + source: 'https://example.com/superpowers.zip', + }, + ], + installed: new Map([['superpowers', '5.0.0']]), + source: '/tmp/marketplace.json', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip).join('\n'); + expect(out).toContain('? Superpowers update 5.0.0 → 5.1.0'); + }); + + it('shows installed with the version when already up to date', () => { + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + source: 'https://example.com/superpowers.zip', + }, + ], + installed: new Map([['superpowers', '5.1.0']]), + source: '/tmp/marketplace.json', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip).join('\n'); + expect(out).toContain(`? Superpowers installed ${MID} v5.1.0`); + }); + it('toggles an installed plugin from the overview with space', () => { const onSelect = vi.fn(); const picker = new PluginsOverviewSelectorComponent({ diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 0df71a811..d7430b5ad 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -6,10 +6,58 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); +describe('computeUpdateStatus', () => { + it('reports not-installed when the plugin is absent', () => { + expect(computeUpdateStatus('1.0.0', undefined, false)).toEqual({ kind: 'not-installed' }); + }); + + it('reports an update when the marketplace version is newer', () => { + expect(computeUpdateStatus('5.1.0', '5.0.0', true)).toEqual({ + kind: 'update', + local: '5.0.0', + latest: '5.1.0', + }); + }); + + it('reports up-to-date when versions match', () => { + expect(computeUpdateStatus('5.1.0', '5.1.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.1.0', + }); + }); + + it('does not offer a downgrade when the local version is ahead', () => { + expect(computeUpdateStatus('3.1.1', '3.2.0', true)).toEqual({ + kind: 'up-to-date', + version: '3.2.0', + }); + }); + + it('never reports an update for non-semver versions', () => { + expect(computeUpdateStatus('latest', '5.0.0', true).kind).toBe('up-to-date'); + expect(computeUpdateStatus('5.1.0', 'dev', true).kind).toBe('up-to-date'); + }); + + it('shows the local version even when the marketplace omits one', () => { + expect(computeUpdateStatus(undefined, '5.0.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.0.0', + }); + }); + + it('does not claim the marketplace version as installed when the local version is unknown', () => { + // No spurious `installed · v`, and no permanent suppression of updates. + expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ + kind: 'up-to-date', + version: undefined, + }); + }); +}); + describe('loadPluginMarketplace', () => { it('loads a local marketplace file and resolves relative plugin sources', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 37699ee0b..2772c4e69 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -15,6 +15,8 @@ Run `/plugins` in the TUI to open the plugin manager, where you can perform all | `M` | Manage MCP servers for the selected plugin | | `←` or `Esc` | Go back to the previous level | +In the marketplace list, an installed plugin with a newer version available shows `update `, an up-to-date one shows `installed · v`, and an uninstalled one shows `install v`. Select an updatable entry and press `Enter` to update. + You can also use slash commands directly: | Command | Description | diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 0c564aebe..220adb5b4 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -15,6 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `M` | 管理选中 plugin 的 MCP servers | | `←` 或 `Esc` | 返回上一层 | +在 marketplace 列表里,已安装且有新版本的 plugin 会显示 `update <本地版本> → <最新版本>`,已是最新显示 `installed · v<版本>`,未安装显示 `install v<版本>`。选中可更新的项按 `Enter` 即可更新。 + 也可以直接使用斜杠命令: | 命令 | 说明 | From 25cf13ac97f924bc190cef5086bbd55cf0592f15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:47:32 +0800 Subject: [PATCH 009/476] ci: release packages (#588) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/active-agent-groups.md | 5 ----- .changeset/custom-theme-support.md | 5 ----- .changeset/import-cc-codex.md | 6 ------ .changeset/login-browser-fallback.md | 5 ----- .changeset/plugin-marketplace-update-badge.md | 5 ----- .changeset/steady-windows-bins.md | 6 ------ .changeset/truncate-queue-line.md | 5 ----- apps/kimi-code/CHANGELOG.md | 20 +++++++++++++++++++ apps/kimi-code/package.json | 2 +- packages/acp-adapter/CHANGELOG.md | 8 ++++++++ packages/acp-adapter/package.json | 2 +- packages/agent-core/CHANGELOG.md | 6 ++++++ packages/agent-core/package.json | 2 +- packages/migration-legacy/CHANGELOG.md | 7 +++++++ packages/migration-legacy/package.json | 2 +- packages/node-sdk/CHANGELOG.md | 6 ++++++ packages/node-sdk/package.json | 2 +- 17 files changed, 52 insertions(+), 42 deletions(-) delete mode 100644 .changeset/active-agent-groups.md delete mode 100644 .changeset/custom-theme-support.md delete mode 100644 .changeset/import-cc-codex.md delete mode 100644 .changeset/login-browser-fallback.md delete mode 100644 .changeset/plugin-marketplace-update-badge.md delete mode 100644 .changeset/steady-windows-bins.md delete mode 100644 .changeset/truncate-queue-line.md diff --git a/.changeset/active-agent-groups.md b/.changeset/active-agent-groups.md deleted file mode 100644 index 25c3e4d47..000000000 --- a/.changeset/active-agent-groups.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Clarify grouped subagent progress with active status breakdowns and elapsed time. diff --git a/.changeset/custom-theme-support.md b/.changeset/custom-theme-support.md deleted file mode 100644 index a8044e2e7..000000000 --- a/.changeset/custom-theme-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. diff --git a/.changeset/import-cc-codex.md b/.changeset/import-cc-codex.md deleted file mode 100644 index 4ec1b2c8e..000000000 --- a/.changeset/import-cc-codex.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": minor -"@moonshot-ai/kimi-code": minor ---- - -Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. diff --git a/.changeset/login-browser-fallback.md b/.changeset/login-browser-fallback.md deleted file mode 100644 index cc27499f4..000000000 --- a/.changeset/login-browser-fallback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix device login to keep the URL and code visible when the browser cannot be opened. diff --git a/.changeset/plugin-marketplace-update-badge.md b/.changeset/plugin-marketplace-update-badge.md deleted file mode 100644 index 0364658b5..000000000 --- a/.changeset/plugin-marketplace-update-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. diff --git a/.changeset/steady-windows-bins.md b/.changeset/steady-windows-bins.md deleted file mode 100644 index b4923ec44..000000000 --- a/.changeset/steady-windows-bins.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/kimi-code-sdk": patch ---- - -Fix Windows builds and development launches that could fail when package binaries resolve to command shims. diff --git a/.changeset/truncate-queue-line.md b/.changeset/truncate-queue-line.md deleted file mode 100644 index 1c437ed56..000000000 --- a/.changeset/truncate-queue-line.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Truncate queued message display to a single line with ellipsis when it exceeds terminal width. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 774187295..24588ae58 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 0.13.0 + +### Minor Changes + +- [#484](https://github.com/MoonshotAI/kimi-code/pull/484) [`f863127`](https://github.com/MoonshotAI/kimi-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. + +- [#582](https://github.com/MoonshotAI/kimi-code/pull/582) [`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. + +- [#593](https://github.com/MoonshotAI/kimi-code/pull/593) [`40506f4`](https://github.com/MoonshotAI/kimi-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. + +### Patch Changes + +- [#587](https://github.com/MoonshotAI/kimi-code/pull/587) [`0abde86`](https://github.com/MoonshotAI/kimi-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time. + +- [#594](https://github.com/MoonshotAI/kimi-code/pull/594) [`f2863af`](https://github.com/MoonshotAI/kimi-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened. + +- [#591](https://github.com/MoonshotAI/kimi-code/pull/591) [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. + +- [#586](https://github.com/MoonshotAI/kimi-code/pull/586) [`7cb4a23`](https://github.com/MoonshotAI/kimi-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width. + ## 0.12.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e7c921521..e0ea455a2 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.12.1", + "version": "0.13.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/packages/acp-adapter/CHANGELOG.md b/packages/acp-adapter/CHANGELOG.md index 7594d537f..512cbaedc 100644 --- a/packages/acp-adapter/CHANGELOG.md +++ b/packages/acp-adapter/CHANGELOG.md @@ -1,5 +1,13 @@ # @moonshot-ai/acp-adapter +## 0.2.4 + +### Patch Changes + +- Updated dependencies [[`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360), [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3)]: + - @moonshot-ai/agent-core@0.12.0 + - @moonshot-ai/kimi-code-sdk@0.9.1 + ## 0.2.3 ### Patch Changes diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index 8a581e66f..46cdcc022 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/acp-adapter", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Agent Client Protocol adapter for kimi-code", "license": "MIT", diff --git a/packages/agent-core/CHANGELOG.md b/packages/agent-core/CHANGELOG.md index 88b688e0c..99907deef 100644 --- a/packages/agent-core/CHANGELOG.md +++ b/packages/agent-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/agent-core +## 0.12.0 + +### Minor Changes + +- [#582](https://github.com/MoonshotAI/kimi-code/pull/582) [`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. + ## 0.11.1 ### Patch Changes diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index bb1bd09c8..3b8aea5cb 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/agent-core", - "version": "0.11.1", + "version": "0.12.0", "private": true, "description": "The unified agent engine for Kimi", "license": "MIT", diff --git a/packages/migration-legacy/CHANGELOG.md b/packages/migration-legacy/CHANGELOG.md index 68a703692..d8ab1f43d 100644 --- a/packages/migration-legacy/CHANGELOG.md +++ b/packages/migration-legacy/CHANGELOG.md @@ -1,5 +1,12 @@ # @moonshot-ai/migration-legacy +## 0.1.12 + +### Patch Changes + +- Updated dependencies [[`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360)]: + - @moonshot-ai/agent-core@0.12.0 + ## 0.1.11 ### Patch Changes diff --git a/packages/migration-legacy/package.json b/packages/migration-legacy/package.json index 9321a0ee1..1c24d2983 100644 --- a/packages/migration-legacy/package.json +++ b/packages/migration-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/migration-legacy", - "version": "0.1.11", + "version": "0.1.12", "description": "Migrate kimi-cli (~/.kimi/) data into kimi-code (~/.kimi-code/).", "license": "MIT", "type": "module", diff --git a/packages/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index 921cab532..96bf6bb9a 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code-sdk +## 0.9.1 + +### Patch Changes + +- [#591](https://github.com/MoonshotAI/kimi-code/pull/591) [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. + ## 0.9.0 ### Minor Changes diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 80ce68f87..efa9bd322 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code-sdk", - "version": "0.9.0", + "version": "0.9.1", "private": true, "description": "TypeScript SDK for the Kimi Code Agent", "license": "MIT", From 99b3748dbe691347a13f387846bf5612a07ff608 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 11:09:20 +0800 Subject: [PATCH 010/476] docs(changelog): sync 0.12.1, 0.13.0 from apps/kimi-code/CHANGELOG.md (#605) --- docs/en/release-notes/changelog.md | 25 +++++++++++++++++++++++++ docs/zh/release-notes/changelog.md | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 891690fb3..52f005db8 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -2,6 +2,31 @@ This page documents the changes in each Kimi Code CLI release. +## 0.13.0 (2026-06-10) + +### Features + +- Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. +- Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. +- Show available plugin updates in the marketplace. + +### Bug Fixes + +- Fix Windows builds and development launches that could fail when package binaries resolve to command shims. +- Fix device login to keep the URL and code visible when the browser cannot be opened. + +### Polish + +- Clarify grouped subagent progress with active status breakdowns and elapsed time. +- Truncate queued message display to a single line with ellipsis when it exceeds terminal width. + +## 0.12.1 (2026-06-09) + +### Bug Fixes + +- Allow obsolete experimental config entries to remain without blocking startup. +- Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests. + ## 0.12.0 (2026-06-09) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index b83f13f3a..b7b585db1 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -2,6 +2,31 @@ 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.13.0(2026-06-10) + +### 新功能 + +- 新增自定义颜色主题。在 `~/.kimi-code/themes/` 中以 JSON 文件定义自己的调色板,或使用内置的 `/custom-theme` Skill 命令生成。 +- 新增 `/import-from-cc-codex` 命令,用于导入选定的 Claude Code 和 Codex 指令、Skills 以及 MCP 设置。 +- 在 marketplace 中显示可用的 plugin 更新。 + +### 修复 + +- 修复 Windows 构建和开发启动可能因 package binary 解析到命令 shim 而失败的问题。 +- 修复设备登录,在浏览器无法打开时保持 URL 和验证码可见。 + +### 优化 + +- 通过活跃状态细分和已用时间,更清晰地展示分组子 Agent 进度。 +- 当排队消息超过终端宽度时,将其截断为单行并显示省略号。 + +## 0.12.1(2026-06-09) + +### 修复 + +- 允许过时的实验性配置条目保留而不阻塞启动。 +- 为 OpenAI 兼容的 Chat Completions 请求透传 xhigh reasoning effort。 + ## 0.12.0(2026-06-09) ### 新功能 From a1b419ab5901d16ab9527eef62bcd468e76b27a3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 10 Jun 2026 12:19:33 +0800 Subject: [PATCH 011/476] fix: stop asking for yolo external writes (#606) --- .changeset/yolo-outside-write-approval.md | 6 + .../permission/policies/file-access-ask.ts | 20 -- .../src/agent/permission/policies/index.ts | 3 - .../agent-core/test/agent/permission.test.ts | 203 ++++-------------- 4 files changed, 52 insertions(+), 180 deletions(-) create mode 100644 .changeset/yolo-outside-write-approval.md diff --git a/.changeset/yolo-outside-write-approval.md b/.changeset/yolo-outside-write-approval.md new file mode 100644 index 000000000..6ad245bd4 --- /dev/null +++ b/.changeset/yolo-outside-write-approval.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +YOLO mode no longer asks before writing or editing files outside the working directory. diff --git a/packages/agent-core/src/agent/permission/policies/file-access-ask.ts b/packages/agent-core/src/agent/permission/policies/file-access-ask.ts index b6eebef56..f71d4b4b1 100644 --- a/packages/agent-core/src/agent/permission/policies/file-access-ask.ts +++ b/packages/agent-core/src/agent/permission/policies/file-access-ask.ts @@ -63,26 +63,6 @@ export class GitControlPathAccessAskPermissionPolicy implements PermissionPolicy } } -export class CwdOutsideFileWriteAskPermissionPolicy implements PermissionPolicy { - readonly name = 'cwd-outside-file-write-ask'; - - constructor(private readonly agent: Agent) {} - - evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { - const cwd = this.agent.config.cwd; - if (cwd.length === 0) return; - const pathClass = this.agent.kaos.pathClass(); - const access = writeFileAccesses(context).find((fileAccess) => { - return !isWithinDirectory(fileAccess.path, cwd, pathClass); - }); - if (access === undefined) return; - return { - kind: 'ask', - reason: fileAccessReason(access, { cwd_outside: true }), - }; - } -} - function fileAccesses(context: PermissionPolicyContext): ToolFileAccess[] { return ( context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ?? diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 7f2caa0f1..b1243ac2d 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -6,7 +6,6 @@ import { DefaultToolApprovePermissionPolicy } from './default-tool-approve'; import { ExitPlanModeReviewAskPermissionPolicy } from './exit-plan-mode-review-ask'; import { FallbackAskPermissionPolicy } from './fallback-ask'; import { - CwdOutsideFileWriteAskPermissionPolicy, GitControlPathAccessAskPermissionPolicy, SensitiveFileAccessAskPermissionPolicy, } from './file-access-ask'; @@ -50,8 +49,6 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy new SensitiveFileAccessAskPermissionPolicy(agent), // Access touches .git or a git control-dir path → ask. new GitControlPathAccessAskPermissionPolicy(agent), - // Write target is outside cwd → ask. Reads and searches outside cwd are allowed without prompting. - new CwdOutsideFileWriteAskPermissionPolicy(agent), // yolo mode → approve. new YoloModeApprovePermissionPolicy(agent), // Swarm mode keeps AgentSwarm available without making it a globally default-approved tool. diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index c631becfc..1246a21ff 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -404,20 +404,16 @@ describe('Permission auto mode', () => { }, ); - it.each( - (['manual', 'yolo'] as const).flatMap((mode) => - [ - [mode, 'Write', { path: '/tmp/notes.md', content: 'x' }, 'write', 'write file'], - [mode, 'Edit', { path: '/tmp/notes.md', old_string: 'a', new_string: 'b' }, 'edit', 'edit file'], - ] as const, - ), - )( - 'requests approval in %s mode for %s outside the cwd', - async (mode, toolName, args, operation, action) => { - const { manager, requestApproval } = makePermissionManager(async () => ({ + it.each([ + ['Write', { path: '/tmp/notes.md', content: 'x' }, 'write', 'write file'], + ['Edit', { path: '/tmp/notes.md', old_string: 'a', new_string: 'b' }, 'edit', 'edit file'], + ] as const)( + 'requests approval in manual mode for %s outside the cwd', + async (toolName, args, operation, action) => { + const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ decision: 'approved', })); - manager.setMode(mode); + manager.setMode('manual'); await expect( manager.beforeToolCall(hookContext({ id: `call_${toolName}`, toolName, args })), @@ -435,9 +431,43 @@ describe('Permission auto mode', () => { }), expect.any(Object), ); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'fallback-ask', + tool_name: toolName, + permission_mode: 'manual', + decision: 'ask', + }), + ); }, ); + it.each([ + ['Write', { path: '/tmp/notes.md', content: 'x' }], + ['Edit', { path: '/tmp/notes.md', old_string: 'a', new_string: 'b' }], + ] as const)('approves %s outside the cwd in yolo mode', async (toolName, args) => { + const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ + decision: 'approved', + })); + manager.setMode('yolo'); + + await expect( + manager.beforeToolCall(hookContext({ id: `call_${toolName}_yolo_outside`, toolName, args })), + ).resolves.toBeUndefined(); + + expect(requestApproval).not.toHaveBeenCalled(); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'yolo-mode-approve', + tool_name: toolName, + permission_mode: 'yolo', + decision: 'approve', + }), + ); + }); + it.each( (['manual', 'yolo'] as const).flatMap((mode) => [ @@ -638,7 +668,7 @@ describe('Permission auto mode', () => { ); }); - it('reuses approve-for-session for repeated outside-workspace writes in yolo mode', async () => { + it('approves repeated outside-workspace writes in yolo mode without session approval', async () => { const { manager, requestApproval } = makePermissionManager(async () => ({ decision: 'approved', scope: 'session', @@ -657,8 +687,8 @@ describe('Permission auto mode', () => { await expect(call()).resolves.toBeUndefined(); await expect(call()).resolves.toBeUndefined(); - expect(requestApproval).toHaveBeenCalledTimes(1); - expect(manager.sessionApprovalRulePatterns).toEqual(['Write(/tmp/notes.md)']); + expect(requestApproval).not.toHaveBeenCalled(); + expect(manager.sessionApprovalRulePatterns).toEqual([]); expect(manager.data().rules).toEqual([]); }); }); @@ -678,7 +708,6 @@ describe('Permission policy chain', () => { 'plan-mode-tool-approve', 'sensitive-file-access-ask', 'git-control-path-access-ask', - 'cwd-outside-file-write-ask', 'yolo-mode-approve', 'swarm-mode-agent-swarm-approve', 'default-tool-approve', @@ -3301,7 +3330,7 @@ describe('Default git CWD Write/Edit permission', () => { expect(requestApproval).toHaveBeenCalledTimes(1); expect(telemetryTrack).toHaveBeenCalledWith( 'permission_policy_decision', - expect.objectContaining({ policy_name: 'cwd-outside-file-write-ask' }), + expect.objectContaining({ policy_name: 'fallback-ask' }), ); expect(telemetryTrack).not.toHaveBeenCalledWith( 'permission_policy_decision', @@ -3357,146 +3386,6 @@ describe('Default git CWD Write/Edit permission', () => { }); }); -describe('CWD outside file write permission policy', () => { - it('falls through when cwd is empty', async () => { - const { manager, requestApproval, telemetryTrack } = makePermissionManager( - async () => ({ decision: 'approved' }), - { cwd: '' }, - ); - - await expect( - manager.beforeToolCall( - hookContext({ - id: 'call_empty_cwd_write', - toolName: 'Write', - args: { path: '/tmp/outside.ts', content: 'x' }, - }), - ), - ).resolves.toBeUndefined(); - - expect(requestApproval).toHaveBeenCalledTimes(1); - expect(telemetryTrack).toHaveBeenCalledWith( - 'permission_policy_decision', - expect.objectContaining({ policy_name: 'fallback-ask' }), - ); - }); - - it('falls through when there are no file write accesses', async () => { - const args = { path: '/tmp/outside.ts', content: 'x' }; - const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ - decision: 'approved', - })); - - await expect( - manager.beforeToolCall( - hookContext({ - id: 'call_no_write_accesses', - toolName: 'Write', - args, - execution: { - ...testExecution('Write', args), - accesses: ToolAccesses.none(), - }, - }), - ), - ).resolves.toBeUndefined(); - - expect(requestApproval).toHaveBeenCalledTimes(1); - expect(telemetryTrack).toHaveBeenCalledWith( - 'permission_policy_decision', - expect.objectContaining({ policy_name: 'fallback-ask' }), - ); - }); - - it('asks when any write access is outside the cwd', async () => { - const args = { path: '/workspace/src/a.ts', content: 'x' }; - const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ - decision: 'approved', - })); - - await expect( - manager.beforeToolCall( - hookContext({ - id: 'call_mixed_write_accesses', - toolName: 'Write', - args, - execution: { - ...testExecution('Write', args), - accesses: [ - { kind: 'file', operation: 'write', path: '/workspace/src/a.ts' }, - { kind: 'file', operation: 'readwrite', path: '/tmp/outside.ts' }, - ], - }, - }), - ), - ).resolves.toBeUndefined(); - - expect(requestApproval).toHaveBeenCalledTimes(1); - expect(telemetryTrack).toHaveBeenCalledWith( - 'permission_policy_decision', - expect.objectContaining({ - policy_name: 'cwd-outside-file-write-ask', - decision: 'ask', - cwd_outside: true, - file_access_operation: 'readwrite', - }), - ); - }); - - it.each([ - ['Read', { path: '/tmp/outside.ts' }], - ['Grep', { pattern: 'TODO', path: '/tmp' }], - ] as const)('does not ask for %s access outside cwd', async (toolName, args) => { - const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ - decision: 'approved', - })); - - await expect( - manager.beforeToolCall( - hookContext({ - id: `call_${toolName}_outside_cwd`, - toolName, - args, - }), - ), - ).resolves.toBeUndefined(); - - expect(requestApproval).not.toHaveBeenCalled(); - expect(telemetryTrack).toHaveBeenCalledWith( - 'permission_policy_decision', - expect.objectContaining({ policy_name: 'default-tool-approve' }), - ); - }); - - it('uses Win32 path semantics for cwd containment', async () => { - const kaos = createFakeKaos({ pathClass: () => 'win32' }); - const args = { path: 'c:\\repo\\src\\a.ts', content: 'x' }; - const { manager, telemetryTrack } = makePermissionManager( - async () => ({ decision: 'approved' }), - { cwd: 'C:\\Repo', kaos }, - ); - - await expect( - manager.beforeToolCall( - hookContext({ - id: 'call_win_inside_cwd', - toolName: 'Write', - args, - execution: { - ...testExecution('Write', args), - accesses: ToolAccesses.writeFile('c:\\repo\\src\\a.ts'), - }, - }), - ), - ).resolves.toBeUndefined(); - - expect(telemetryTrack).not.toHaveBeenCalledWith( - 'permission_policy_decision', - expect.objectContaining({ policy_name: 'cwd-outside-file-write-ask' }), - ); - }); -}); - describe('Permission rule helpers', () => { it('parses permission patterns used by rule matching', () => { expect(parsePattern('Write')).toEqual({ toolName: 'Write' }); From 2ebe38769fc50215a7c94a362cd4e943130e1143 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:22:25 +0800 Subject: [PATCH 012/476] feat(agent-core): strengthen Edit-over-Write preference in tool prompts (#540) --- .changeset/tighten-file-tool-guidance.md | 6 ++++++ .../agent-core/src/tools/builtin/file/edit.md | 21 +++++++++++-------- .../src/tools/builtin/file/write.md | 11 +++++++++- packages/agent-core/test/tools/edit.test.ts | 18 +++++++++------- packages/agent-core/test/tools/write.test.ts | 16 +++++++------- 5 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 .changeset/tighten-file-tool-guidance.md diff --git a/.changeset/tighten-file-tool-guidance.md b/.changeset/tighten-file-tool-guidance.md new file mode 100644 index 000000000..6ea5b6d3d --- /dev/null +++ b/.changeset/tighten-file-tool-guidance.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Tighten file tool guidance to route incremental edits through Edit. diff --git a/packages/agent-core/src/tools/builtin/file/edit.md b/packages/agent-core/src/tools/builtin/file/edit.md index f5de2c551..3123f33fa 100644 --- a/packages/agent-core/src/tools/builtin/file/edit.md +++ b/packages/agent-core/src/tools/builtin/file/edit.md @@ -1,10 +1,13 @@ -Perform exact string replacements against the text view returned by Read. +Perform exact replacements in existing files. -- When copying from Read output, omit the line-number prefix and tab; match only the file content. -- By default, old_string must occur exactly once. If it matches multiple locations, add surrounding context or set replace_all when every occurrence should change. -- Prefer Edit for targeted changes to existing files; use Write only for new files or complete overwrites. -- To modify a file, always use Edit; do not run a Shell `sed` command for edits. -- When making several independent changes, issue multiple Edit calls in parallel within a single response; edits to the same file are serialized automatically by a write lock. -- When several parallel Edit calls target the same file, a write lock serializes them; they apply in the order the calls appear in your response. An edit fails with `old_string not found` if its old_string was taken from text an earlier edit already replaced — base every old_string on the latest Read view and order dependent edits accordingly. -- For pure CRLF files, Read shows LF and Edit.old_string/new_string should use LF; Edit writes the file back with CRLF preserved. -- For mixed line endings or lone carriage returns, Read displays carriage returns as \r; include actual \r escapes in old_string/new_string for those positions. \ No newline at end of file +- Edit is mandatory for every incremental change, especially small edits. DO NOT use Write or Bash `sed`. +- Read the target file before every Edit. DO NOT call Edit from memory, stale context, or a guessed `old_string`. +- Take `old_string` and `new_string` from the Read output view. +- Drop the line-number prefix and tab; match only file content. +- `old_string` must be unique unless `replace_all` is set. +- If `old_string` is ambiguous, add surrounding context. Use `replace_all` only when every occurrence should change. +- Multiple Edit calls may run in one response only when they do not target the same file. +- DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's `old_string`, causing `old_string not found`. Read the file again before the next Edit. +- A write lock serializes same-file edits in response order, but serialization does not make stale `old_string` valid. +- For pure CRLF files, Read shows LF; use LF in `old_string` and `new_string`, and Edit writes CRLF back. +- For mixed endings or lone carriage returns, Read shows carriage returns as \r; include actual \r escapes in those positions. diff --git a/packages/agent-core/src/tools/builtin/file/write.md b/packages/agent-core/src/tools/builtin/file/write.md index 5d82cd148..360805e5c 100644 --- a/packages/agent-core/src/tools/builtin/file/write.md +++ b/packages/agent-core/src/tools/builtin/file/write.md @@ -1 +1,10 @@ -Overwrite or append to a file with content exactly as provided, creating the file if needed; the parent directory must already exist. Defaults to overwrite; append adds content to the end without adding a newline. Write does not use the Read/Edit model text view and does not preserve or infer the previous line-ending style: \n stays LF, \r\n stays CRLF. Use Edit for targeted changes to existing files. When the content is very large, you can split it across multiple calls: write the first chunk with overwrite, then add the remaining chunks with append. +Create, append to, or replace a file entirely. + +- The parent directory must already exist. +- Mode defaults to overwrite; append adds content at EOF without adding a newline. +- Write is NOT ALLOWED for incremental changes to existing files, including trivial, one-line, quick, or cosmetic edits. Use Edit instead. +- Use Write only when the file does not exist, you intend a complete replacement, or the new contents have little continuity with the old contents. +- Read before overwriting an existing file. +- Write ignores the Read/Edit line-number view. NEVER include line prefixes. +- Write outputs content literally, including supplied line endings: \n stays LF, \r\n stays CRLF. +- For new content too large for one call, overwrite the first chunk, then append subsequent chunks. Never chunk Write to modify an existing file. diff --git a/packages/agent-core/test/tools/edit.test.ts b/packages/agent-core/test/tools/edit.test.ts index 315049a3f..6e0ad2010 100644 --- a/packages/agent-core/test/tools/edit.test.ts +++ b/packages/agent-core/test/tools/edit.test.ts @@ -34,15 +34,19 @@ describe('EditTool', () => { const tool = new EditTool(createFakeKaos(), PERMISSIVE_WORKSPACE); expect(tool.name).toBe('Edit'); - expect(tool.description).toContain('text view returned by Read'); - expect(tool.description).toContain('omit the line-number prefix'); - expect(tool.description).toContain('old_string must occur exactly once'); - expect(tool.description).toContain('multiple Edit calls in parallel'); - // Editing files should go through Edit, not a Shell `sed` command. - expect(tool.description).toContain('Shell `sed`'); + expect(tool.description).toContain('Read the target file before every Edit'); + expect(tool.description).toContain('DO NOT call Edit from memory'); + expect(tool.description).toContain('Read output view'); + expect(tool.description).toContain('line-number prefix'); + expect(tool.description).toContain('`old_string` must be unique'); + expect(tool.description).toContain('only when they do not target the same file'); + expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file'); + // Editing files should go through Edit, not Write and not a Bash `sed` + // command. The prompt names both alternatives explicitly. + expect(tool.description).toContain('DO NOT use Write or Bash `sed`'); // Parallel Edit calls on the same file are serialized and applied in // response order; mismatched old_string fails explicitly. - expect(tool.description).toContain('they apply in the order the calls appear in your response'); + expect(tool.description).toContain('same-file edits in response order'); expect(tool.description).toContain('old_string not found'); expect(tool.parameters).toMatchObject({ type: 'object', diff --git a/packages/agent-core/test/tools/write.test.ts b/packages/agent-core/test/tools/write.test.ts index e7d48209d..2c52f1959 100644 --- a/packages/agent-core/test/tools/write.test.ts +++ b/packages/agent-core/test/tools/write.test.ts @@ -18,9 +18,11 @@ describe('WriteTool', () => { const tool = new WriteTool(createFakeKaos(), PERMISSIVE_WORKSPACE); expect(tool.name).toBe('Write'); - expect(tool.description).toContain('exactly as provided'); - expect(tool.description).toContain('append adds content to the end without adding a newline'); - expect(tool.description).toContain('does not preserve or infer the previous line-ending style'); + expect(tool.description).toContain('append adds content at EOF without adding a newline'); + expect(tool.description).toContain('\\n stays LF, \\r\\n stays CRLF'); + // The prompt steers the agent toward Edit for partial changes to an + // existing file. Pin the prohibition so accidental weakening is caught. + expect(tool.description).toContain('Write is NOT ALLOWED for incremental changes'); expect(tool.parameters).toMatchObject({ type: 'object', properties: { @@ -93,11 +95,11 @@ describe('WriteTool', () => { it('guides batching large content across multiple write calls', () => { const tool = new WriteTool(createFakeKaos(), PERMISSIVE_WORKSPACE); - // The guidance must mention splitting large content across multiple calls, - // and spell out the first-overwrite-then-append ordering. + // The guidance must mention that a file too large for one call should be + // chunked, and spell out the first-overwrite-then-append ordering. expect(tool.description).toMatch(/large/i); - expect(tool.description).toMatch(/split[^.]*multiple calls/i); - expect(tool.description).toMatch(/first[^.]*overwrite[^.]*then[^.]*append/i); + expect(tool.description).toContain('content too large for one call'); + expect(tool.description).toMatch(/overwrite[^.]*first chunk[^.]*then[^.]*append/i); }); it('writes content through kaos and reports bytes written', async () => { From 1580f35136eed02331dcff6c8482247d5cf35458 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Wed, 10 Jun 2026 12:42:27 +0800 Subject: [PATCH 013/476] fix: align datasource plugin environment (#595) * fix: align datasource plugin environment * refactor: inject managed Kimi env into all stdio plugins Pin the datasource credential-name test to the canonical resolveKimiCodeOAuthKey so a digest drift in the standalone plugin fails CI, and drop the hardcoded plugin-name special case so every stdio plugin receives the active managed Kimi base URL / OAuth host consistently (process.env and KIMI_CODE_HOME are already shared with all plugins). --- .changeset/datasource-env-oauth.md | 6 + .../test/utils/kimi-datasource-plugin.test.ts | 104 +++++++++++++++++- docs/en/customization/plugins.md | 2 +- docs/zh/customization/plugins.md | 2 +- packages/agent-core/src/rpc/core-impl.ts | 36 +++++- .../agent-core/test/rpc/plugins-rpc.test.ts | 66 +++++++++++ plugins/marketplace.json | 2 +- plugins/official/kimi-datasource/CHANGELOG.md | 4 + plugins/official/kimi-datasource/SKILL.md | 2 +- .../kimi-datasource/bin/kimi-datasource.mjs | 58 +++++++++- .../official/kimi-datasource/kimi.plugin.json | 2 +- 11 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 .changeset/datasource-env-oauth.md diff --git a/.changeset/datasource-env-oauth.md b/.changeset/datasource-env-oauth.md new file mode 100644 index 000000000..2a34572ad --- /dev/null +++ b/.changeset/datasource-env-oauth.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core": patch +--- + +Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. diff --git a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts index 3219cc80d..13f9d38d0 100644 --- a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts +++ b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; +import { resolveKimiCodeOAuthKey } from '@moonshot-ai/kimi-code-oauth'; import { describe, expect, it } from 'vitest'; const REPO_ROOT = join(import.meta.dirname, '../../../..'); @@ -114,12 +115,98 @@ describe('kimi-datasource MCP server', () => { await expect(readFile(blockedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); expect(requests).toEqual([ { + authorization: 'Bearer test-token', method: 'call_data_source_tool', params: { data_source_name: 'world_bank_open_data', api_name: 'world_bank_open_data', params: { filepath: textFile }, }, + url: '/', + }, + ]); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('uses env-scoped credentials and derives the datasource URL from KIMI_CODE_BASE_URL', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + const requests: unknown[] = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleMockDatasourceRequest(request, response, { + requests, + textFile: join(tempDir, 'unused.csv'), + binaryFile: join(tempDir, 'unused_payload.csv'), + blockedFile: join(tempDir, 'blocked.csv'), + }); + }); + + try { + await listen(server); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`; + const oauthHost = 'https://auth.dev.kimi.team'; + const scopedCredential = kimiCodeEnvCredentialName({ oauthHost, baseUrl }); + + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'expired-prod-token', expires_at: 1 }), + 'utf8', + ); + await writeFile( + join(kimiHome, 'credentials', `${scopedCredential}.json`), + JSON.stringify({ access_token: 'scoped-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_CODE_BASE_URL: baseUrl, + KIMI_CODE_OAUTH_HOST: oauthHost, + KIMI_DATASOURCE_API_URL: undefined, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { + name: 'arxiv', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(requests).toEqual([ + { + authorization: 'Bearer scoped-token', + method: 'get_data_source_desc', + params: { name: 'arxiv' }, + url: '/coding/v1/tools', }, ]); } finally { @@ -131,6 +218,17 @@ describe('kimi-datasource MCP server', () => { }); }); +// Pin the expected credential file name to the canonical OAuth-key resolver so +// this test fails if the plugin's standalone digest drifts from the source of +// truth in @moonshot-ai/kimi-code-oauth. The credential file name is the OAuth +// key with its `oauth/` prefix stripped. +function kimiCodeEnvCredentialName(options: { + readonly oauthHost: string; + readonly baseUrl: string; +}): string { + return resolveKimiCodeOAuthKey(options).replace(/^oauth\//, ''); +} + async function readJson(request: IncomingMessage): Promise { let body = ''; for await (const chunk of request) { @@ -150,7 +248,11 @@ async function handleMockDatasourceRequest( }, ): Promise { try { - options.requests.push(await readJson(request)); + options.requests.push({ + ...(await readJson(request) as Record), + authorization: request.headers.authorization, + url: request.url, + }); response.setHeader('Content-Type', 'application/json'); response.end( JSON.stringify({ diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 2772c4e69..d2d71d4b5 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -59,7 +59,7 @@ Kimi Datasource is the official Kimi Code data plugin. It lets you query financi ### Installation -You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services. +You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services. When you run Kimi Code with `KIMI_CODE_OAUTH_HOST` or `KIMI_CODE_BASE_URL`, log in under the same environment so the datasource plugin uses the matching credentials and service endpoint. 1. Run `/plugins` and select **Marketplace** 2. Find **Kimi Datasource** and press `Space` to install diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 220adb5b4..f851ea8fe 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -59,7 +59,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 ### 安装 -需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。 +需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。当你通过 `KIMI_CODE_OAUTH_HOST` 或 `KIMI_CODE_BASE_URL` 运行 Kimi Code 时,需要在同一环境下登录,这样 datasource 插件会使用匹配的凭据和服务端点。 1. 运行 `/plugins`,选择 **Marketplace** 2. 找到 **Kimi Datasource**,按 `Space` 安装 diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c9f74d36a..acd2888a1 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -20,6 +20,7 @@ import { resolveKimiHome, writeConfigFile, type KimiConfig, + type McpServerConfig, type MoonshotServiceConfig, } from '../config'; import { @@ -98,6 +99,9 @@ import { KaosShellNotFoundError, LocalKaos, type Kaos } from '@moonshot-ai/kaos' import type { ToolServices } from '../tools/support/services'; const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; +const KIMI_CODE_BASE_URL_ENV = 'KIMI_CODE_BASE_URL'; +const KIMI_CODE_OAUTH_HOST_ENV = 'KIMI_CODE_OAUTH_HOST'; +const KIMI_OAUTH_HOST_ENV = 'KIMI_OAUTH_HOST'; type AgentScopedPayload = T & { readonly agentId: string }; type SessionScopedPayload = T & { readonly sessionId: string }; type SessionAgentPayload = SessionScopedPayload>; @@ -797,7 +801,7 @@ export class KimiCore implements PromisableMethods { } private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined { - const pluginServers = this.plugins.enabledMcpServers(); + const pluginServers = this.withManagedKimiPluginEnv(this.plugins.enabledMcpServers()); if (Object.keys(pluginServers).length === 0) return base; return { servers: { @@ -807,6 +811,36 @@ export class KimiCore implements PromisableMethods { }; } + private withManagedKimiPluginEnv( + pluginServers: Record, + ): Record { + const managedEnv = this.managedKimiCodeEnvForPlugins(); + if (Object.keys(managedEnv).length === 0) return pluginServers; + + const out: Record = {}; + for (const [name, server] of Object.entries(pluginServers)) { + out[name] = + server.transport === 'stdio' + ? { ...server, env: { ...server.env, ...managedEnv } } + : server; + } + return out; + } + + private managedKimiCodeEnvForPlugins(): Record { + const provider = this.config.providers[KIMI_CODE_PROVIDER_NAME]; + const envBaseUrl = process.env[KIMI_CODE_BASE_URL_ENV]; + const envOAuthHost = process.env[KIMI_CODE_OAUTH_HOST_ENV] ?? process.env[KIMI_OAUTH_HOST_ENV]; + const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; + const baseUrl = + envBaseUrl !== undefined ? envBaseUrl.replace(/\/+$/, '') : provider?.baseUrl; + const oauthHost = hasEnvOverride ? envOAuthHost : provider?.oauth?.oauthHost; + const env: Record = {}; + if (baseUrl !== undefined) env[KIMI_CODE_BASE_URL_ENV] = baseUrl; + if (oauthHost !== undefined) env[KIMI_CODE_OAUTH_HOST_ENV] = oauthHost; + return env; + } + private sessionApi(sessionId: string): SessionAPIImpl { const session = this.sessions.get(sessionId); if (session === undefined) { diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index f3068f202..5fa2c8538 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -83,6 +83,64 @@ describe('KimiCore plugin RPCs', () => { ); }); + it('injects persisted managed Kimi Code environment into the datasource plugin MCP server', async () => { + const previousBaseUrl = process.env['KIMI_CODE_BASE_URL']; + const previousCodeOAuthHost = process.env['KIMI_CODE_OAUTH_HOST']; + const previousOAuthHost = process.env['KIMI_OAUTH_HOST']; + delete process.env['KIMI_CODE_BASE_URL']; + delete process.env['KIMI_CODE_OAUTH_HOST']; + delete process.env['KIMI_OAUTH_HOST']; + + const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-')); + const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-')); + try { + await writeFile( + path.join(home, 'config.toml'), + ` +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://coding.deva.msh.team/coding/v1" +api_key = "" +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" } +`, + 'utf8', + ); + await writeFile( + path.join(pluginRoot, 'kimi.plugin.json'), + JSON.stringify({ + name: 'kimi-datasource', + mcpServers: { + data: { command: 'node', args: ['./bin/kimi-datasource.mjs'] }, + }, + }), + 'utf8', + ); + + const core = new KimiCore(async () => ({}) as never, { homeDir: home }); + await new Promise((r) => setImmediate(r)); + await core.installPlugin({ source: pluginRoot }); + + const mcpConfig = ( + core as unknown as { + mergePluginMcpConfig(base: undefined): { + servers: Record }>; + }; + } + ).mergePluginMcpConfig(undefined); + + expect(mcpConfig.servers['plugin-kimi-datasource:data']?.env).toEqual( + expect.objectContaining({ + KIMI_CODE_BASE_URL: 'https://coding.deva.msh.team/coding/v1', + KIMI_CODE_OAUTH_HOST: 'https://auth.dev.kimi.team', + }), + ); + } finally { + restoreEnv('KIMI_CODE_BASE_URL', previousBaseUrl); + restoreEnv('KIMI_CODE_OAUTH_HOST', previousCodeOAuthHost); + restoreEnv('KIMI_OAUTH_HOST', previousOAuthHost); + } + }); + it('throws PLUGIN_LOAD_FAILED on every RPC when installed.json is corrupt', async () => { const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-')); await mkdir(path.join(home, 'plugins'), { recursive: true }); @@ -149,3 +207,11 @@ describe('KimiCore plugin RPCs', () => { ); }); }); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 21f4bb9a3..baa5da7ad 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -5,7 +5,7 @@ "id": "kimi-datasource", "tier": "official", "displayName": "Kimi Datasource", - "version": "3.1.1", + "version": "3.1.2", "description": "Official datasource workflows.", "keywords": ["data", "mcp"], "source": "./official/kimi-datasource" diff --git a/plugins/official/kimi-datasource/CHANGELOG.md b/plugins/official/kimi-datasource/CHANGELOG.md index 0379231e2..9c5280f25 100644 --- a/plugins/official/kimi-datasource/CHANGELOG.md +++ b/plugins/official/kimi-datasource/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.1.2 - 2026-06-09 + +- Use OAuth credentials and datasource endpoints that match the active Kimi Code environment. + ## 3.1.1 - 2026-06-02 - Refine skill activation wording and answer-language guidance. diff --git a/plugins/official/kimi-datasource/SKILL.md b/plugins/official/kimi-datasource/SKILL.md index 4b9a4584f..21b91b6ec 100644 --- a/plugins/official/kimi-datasource/SKILL.md +++ b/plugins/official/kimi-datasource/SKILL.md @@ -16,7 +16,7 @@ description: | 这两个工具由 Kimi Code 托管执行,参数直接按 tool schema 传 JSON。 -工具会读取 `$KIMI_CODE_HOME/credentials/kimi-code.json`。如果没有登录凭据,让用户先在 Kimi Code 里执行 `/login`。 +工具会读取当前 Kimi Code 环境对应的本地 OAuth 登录凭据;当设置了 `KIMI_CODE_OAUTH_HOST` / `KIMI_CODE_BASE_URL` 时,会使用对应环境的隔离凭据。如果没有登录凭据,让用户先在 Kimi Code 里执行 `/login`。 ## 1. 这个 skill 提供什么能力 diff --git a/plugins/official/kimi-datasource/bin/kimi-datasource.mjs b/plugins/official/kimi-datasource/bin/kimi-datasource.mjs index 0803e917d..5f44ef92a 100755 --- a/plugins/official/kimi-datasource/bin/kimi-datasource.mjs +++ b/plugins/official/kimi-datasource/bin/kimi-datasource.mjs @@ -9,17 +9,19 @@ // - tools/call // - ping // -// Business logic (API call, credentials, headers) is unchanged from the -// previous one-shot CLI; only the transport changed. +// Business logic is kept self-contained so the plugin can run from a zipped +// marketplace install without workspace package dependencies. -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { arch, homedir, hostname, release, type } from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; -const VERSION = '3.1.1'; -const API_URL = process.env.KIMI_DATASOURCE_API_URL ?? 'https://api.kimi.com/coding/v1/tools'; +const VERSION = '3.1.2'; +const DEFAULT_KIMI_CODE_OAUTH_HOST = 'https://auth.kimi.com'; +const DEFAULT_KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; +const API_URL = datasourceApiUrl(); const REQUEST_TIMEOUT_MS = 30_000; const PROTOCOL_VERSION = '2025-06-18'; @@ -209,9 +211,53 @@ function resolveKimiHome() { return explicit && explicit.length > 0 ? explicit : path.join(homedir(), '.kimi-code'); } +function datasourceApiUrl() { + const explicit = process.env.KIMI_DATASOURCE_API_URL?.trim(); + if (explicit !== undefined && explicit.length > 0) return explicit; + return `${kimiCodeBaseUrl()}/tools`; +} + +function kimiCodeBaseUrl() { + return (process.env.KIMI_CODE_BASE_URL ?? DEFAULT_KIMI_CODE_BASE_URL).replace(/\/+$/, ''); +} + +function kimiCodeOAuthHost() { + return normalizeEndpoint( + process.env.KIMI_CODE_OAUTH_HOST ?? + process.env.KIMI_OAUTH_HOST ?? + DEFAULT_KIMI_CODE_OAUTH_HOST, + ); +} + +function normalizeEndpoint(value) { + return value.trim().replace(/\/+$/, ''); +} + +function resolveKimiCodeCredentialName() { + const oauthHost = kimiCodeOAuthHost(); + const baseUrl = kimiCodeBaseUrl(); + if ( + oauthHost === normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST) && + baseUrl === DEFAULT_KIMI_CODE_BASE_URL + ) { + return 'kimi-code'; + } + + // Keep this in sync with packages/oauth/src/managed-kimi-code.ts. + const digest = createHash('sha256') + .update(JSON.stringify({ oauthHost, baseUrl })) + .digest('hex') + .slice(0, 16); + return `kimi-code-env-${digest}`; +} + async function loadAccessToken() { const kimiHome = resolveKimiHome(); - const credentialsFile = path.join(kimiHome, 'credentials', 'kimi-code.json'); + const credentialsFile = path.join( + kimiHome, + 'credentials', + `${resolveKimiCodeCredentialName()}.json`, + ); let parsed; try { parsed = JSON.parse(await readFile(credentialsFile, 'utf8')); diff --git a/plugins/official/kimi-datasource/kimi.plugin.json b/plugins/official/kimi-datasource/kimi.plugin.json index e858f025e..3f43faba0 100644 --- a/plugins/official/kimi-datasource/kimi.plugin.json +++ b/plugins/official/kimi-datasource/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "kimi-datasource", - "version": "3.1.1", + "version": "3.1.2", "description": "Finance, macro, enterprise, and academic data tools for Kimi Code.", "keywords": ["finance", "data-source", "mcp"], "mcpServers": { From 32d708083730c14090f855b1fcb650e2bc713797 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 12:46:23 +0800 Subject: [PATCH 014/476] fix(skill): clarify active skill prompts (#598) * fix(skill): clarify active skill prompts * fix(skill): preserve nested skill trigger --- .changeset/clear-skill-reminders.md | 6 ++ packages/agent-core/src/agent/skill/index.ts | 19 ++-- packages/agent-core/src/agent/skill/prompt.ts | 57 ++++++++++++ .../tools/builtin/collaboration/skill-tool.ts | 20 +++- .../test/agent/skill-tool-manager.test.ts | 8 +- .../test/harness/skill-session.test.ts | 92 +++++++++++++++++-- .../test/tools/skill-tool-dispatch.test.ts | 18 +++- .../agent-core/test/tools/skill-tool.test.ts | 43 ++++++--- packages/node-sdk/test/session-skills.test.ts | 10 +- 9 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 .changeset/clear-skill-reminders.md create mode 100644 packages/agent-core/src/agent/skill/prompt.ts diff --git a/.changeset/clear-skill-reminders.md b/.changeset/clear-skill-reminders.md new file mode 100644 index 000000000..b61b1ef4a --- /dev/null +++ b/.changeset/clear-skill-reminders.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Clarify active skill prompts so loaded skills are no longer represented as system reminders. diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 19d08af71..4a9d55332 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -6,8 +6,8 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; import { ErrorCodes, KimiError } from '#/errors'; import { isUserActivatableSkillType, type SkillRegistry } from '../../skill'; -import { escapeXml } from '#/utils/xml-escape'; import type { SkillActivationOrigin } from '../context'; +import { renderUserSlashSkillPrompt } from './prompt'; export class SkillManager { constructor( @@ -24,18 +24,17 @@ export class SkillManager { throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`); } - const skillContent = this.registry.renderSkillPrompt(skill, input.args ?? ''); - const args = input.args; - const argsAttr = args ? ` args="${escapeXml(args)}"` : ''; + const skillArgs = input.args ?? ''; + const skillContent = this.registry.renderSkillPrompt(skill, skillArgs); const wrapped = [ { type: 'text' as const, - text: - `\n` + - `\n` + - `${skillContent}\n` + - `\n` + - ``, + text: renderUserSlashSkillPrompt({ + skillName: skill.name, + skillArgs, + skillContent, + skillSource: skill.source, + }), }, ]; diff --git a/packages/agent-core/src/agent/skill/prompt.ts b/packages/agent-core/src/agent/skill/prompt.ts new file mode 100644 index 000000000..2ea21076b --- /dev/null +++ b/packages/agent-core/src/agent/skill/prompt.ts @@ -0,0 +1,57 @@ +import { escapeXml } from '#/utils/xml-escape'; +import type { SkillSource } from '../../skill'; + +export type SkillPromptTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; + +export interface RenderSkillPromptInput { + readonly skillName: string; + readonly skillArgs: string; + readonly skillContent: string; + readonly skillSource?: SkillSource | undefined; +} + +interface RenderSkillLoadedBlockInput extends RenderSkillPromptInput { + readonly trigger: SkillPromptTrigger; +} + +export function renderUserSlashSkillPrompt(input: RenderSkillPromptInput): string { + return [ + `User activated the skill "${escapeXml(input.skillName)}". Follow the loaded skill instructions.`, + '', + renderSkillLoadedBlock({ ...input, trigger: 'user-slash' }), + ].join('\n'); +} + +export interface RenderModelToolSkillPromptInput extends RenderSkillPromptInput { + readonly trigger: Extract; +} + +export function renderModelToolSkillPrompt(input: RenderModelToolSkillPromptInput): string { + return [ + 'Skill tool loaded instructions for this request. Follow them.', + '', + renderSkillLoadedBlock({ ...input, trigger: input.trigger }), + ].join('\n'); +} + +export function renderSkillLoadedBlock(input: RenderSkillLoadedBlockInput): string { + return [ + ``, + input.skillContent, + '', + ].join('\n'); +} + +function renderSkillAttributes(input: RenderSkillLoadedBlockInput): string { + const attrs: ReadonlyArray = [ + ['name', input.skillName], + ['trigger', input.trigger], + ['source', input.skillSource], + ['args', input.skillArgs], + ]; + + return attrs + .filter((item): item is readonly [string, string] => item[1] !== undefined) + .map(([name, value]) => ` ${name}="${escapeXml(value)}"`) + .join(''); +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts index 1dfe27ab1..c6284266b 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -17,11 +17,11 @@ import { z } from 'zod'; import type { Agent } from '../../../agent'; import type { SkillActivationOrigin } from '../../../agent/context'; +import { renderModelToolSkillPrompt } from '../../../agent/skill/prompt'; import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { isInlineSkillType, type SkillDefinition } from '../../../skill'; import { renderPrompt } from '../../../utils/render-prompt'; -import { escapeXml } from '../../../utils/xml-escape'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; import skillDescriptionTemplate from './skill-tool.md'; @@ -129,12 +129,22 @@ export class SkillTool implements BuiltinTool { } const origin = skillOrigin(skill, skillArgs, currentDepth); + const promptTrigger = origin.trigger === 'nested-skill' ? 'nested-skill' : 'model-tool'; skills.recordActivation(origin); const skillContent = skills.registry.renderSkillPrompt(skill, skillArgs); - this.agent.context.appendSystemReminder( - `\n` + - `${skillContent}\n` + - ``, + this.agent.context.appendUserMessage( + [ + { + type: 'text' as const, + text: renderModelToolSkillPrompt({ + skillName: skill.name, + skillArgs, + skillContent, + skillSource: skill.source, + trigger: promptTrigger, + }), + }, + ], origin, ); return { diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index d047457ec..bedcf81f8 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -154,7 +154,13 @@ describe('ToolManager SkillTool registration', () => { content: [ { type: 'text', - text: '\n\nbody of review\n\n', + text: [ + 'Skill tool loaded instructions for this request. Follow them.', + '', + '', + 'body of review', + '', + ].join('\n'), }, ], origin: { diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index c14336b1f..e51e318f7 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -193,9 +193,15 @@ describe('HarnessAPI session skills', () => { const records = await readMainWire(created.sessionDir); const prompt = records.find((record) => record['type'] === 'turn.prompt'); const userMessage = records.find((record) => record['type'] === 'context.append_message'); - const expectedPrompt = - '\n\n' + - 'Review the requested file.\n\nARGUMENTS: src/app.ts\n\n'; + const expectedPrompt = [ + 'User activated the skill "phase-one-review". Follow the loaded skill instructions.', + '', + '', + 'Review the requested file.', + '', + 'ARGUMENTS: src/app.ts', + '', + ].join('\n'); expect(prompt).toMatchObject({ type: 'turn.prompt', input: [{ type: 'text', text: expectedPrompt }], @@ -242,6 +248,9 @@ describe('HarnessAPI session skills', () => { skillSource: 'project', }, }); + expect(expectedPrompt).not.toContain(''); + expect(expectedPrompt).toContain('trigger="user-slash"'); + expect(expectedPrompt).toContain('User activated the skill "phase-one-review".'); }); it('expands skill body placeholders on user slash activation', async () => { @@ -275,15 +284,15 @@ describe('HarnessAPI session skills', () => { const prompt = records.find((record) => record['type'] === 'turn.prompt'); const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review')); const expectedPrompt = [ - '', - '', + 'User activated the skill "templated-review". Follow the loaded skill instructions.', + '', + '', 'Target: src/app.ts', 'Mode: careful', 'Raw: "src/app.ts" careful', `Dir: ${skillDir}`, 'Session: ses_skill_template', '', - '', ].join('\n'); expect(prompt).toMatchObject({ type: 'turn.prompt', @@ -297,6 +306,67 @@ describe('HarnessAPI session skills', () => { expect(JSON.stringify(prompt)).not.toContain('ARGUMENTS:'); }); + it('represents no-args user slash skill activation as the current user request', async () => { + await writeSkill('brainstorm', [ + '---', + 'name: brainstorm', + 'description: Brainstorm before implementation', + '---', + '', + 'Ask one clarifying question before proposing designs.', + ]); + const { core, rpc } = await createTestRpc({ homeDir }); + const created = await rpc.createSession({ id: 'ses_skill_no_args', workDir }); + + await rpc.activateSkill({ + sessionId: created.id, + agentId: 'main', + name: 'brainstorm', + }); + await core.sessions.get(created.id)?.flushMetadata(); + + const records = await readMainWire(created.sessionDir); + const prompt = records.find((record) => record['type'] === 'turn.prompt'); + const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text; + + expect(text).toContain('User activated the skill "brainstorm". Follow the loaded skill instructions.'); + expect(text).toContain( + '', + ); + expect(text).toContain('Ask one clarifying question before proposing designs.'); + expect(text).not.toContain(''); + }); + + it('escapes user slash skill args in loaded-skill boundaries', async () => { + await writeSkill('unsafe-args', [ + '---', + 'name: unsafe-args', + 'description: Check unsafe args', + '---', + '', + 'Inspect the requested input.', + ]); + const { core, rpc } = await createTestRpc({ homeDir }); + const created = await rpc.createSession({ id: 'ses_skill_unsafe_args', workDir }); + + await rpc.activateSkill({ + sessionId: created.id, + agentId: 'main', + name: 'unsafe-args', + args: '', + }); + await core.sessions.get(created.id)?.flushMetadata(); + + const records = await readMainWire(created.sessionDir); + const prompt = records.find((record) => record['type'] === 'turn.prompt'); + const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text; + + expect(text).toContain('args="</kimi-skill-loaded></system-reminder>"'); + expect(text).toContain('ARGUMENTS: </kimi-skill-loaded></system-reminder>'); + expect(text).not.toContain('args=""'); + expect(text).not.toContain(''); + }); + it('records legacy flow telemetry when activating a flow skill', async () => { await writeSkill('review-flow', [ '---', @@ -369,7 +439,15 @@ describe('HarnessAPI session skills', () => { content: [ { type: 'text', - text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', + text: [ + 'User activated the skill "phase-one-review". Follow the loaded skill instructions.', + '', + '', + 'Review the requested file.', + '', + 'ARGUMENTS: src/app.ts', + '', + ].join('\n'), }, ], origin: { diff --git a/packages/agent-core/test/tools/skill-tool-dispatch.test.ts b/packages/agent-core/test/tools/skill-tool-dispatch.test.ts index 32701dea2..89b86b365 100644 --- a/packages/agent-core/test/tools/skill-tool-dispatch.test.ts +++ b/packages/agent-core/test/tools/skill-tool-dispatch.test.ts @@ -35,12 +35,17 @@ function registry(skills: readonly SkillDefinition[] = []): SkillRegistry { interface SkillToolMethods { readonly recordSkillActivation: (origin: SkillActivationOrigin) => void; readonly recordSystemReminder: (content: string, origin: SkillActivationOrigin) => void; + readonly recordUserMessage: ( + content: readonly [{ readonly type: 'text'; readonly text: string }], + origin: SkillActivationOrigin, + ) => void; } function skillToolMethods() { return { recordSkillActivation: vi.fn(), recordSystemReminder: vi.fn(), + recordUserMessage: vi.fn(), } satisfies SkillToolMethods; } @@ -52,6 +57,7 @@ function skillToolAgent(skills: SkillRegistry, methods: SkillToolMethods): Agent }, context: { appendSystemReminder: methods.recordSystemReminder, + appendUserMessage: methods.recordUserMessage, }, } as unknown as Agent; } @@ -82,7 +88,12 @@ describe('SkillTool dispatch edges', () => { expect(result.output).toContain('loaded inline'); expect(result.output).not.toContain('body of prompt-skill'); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain('body of prompt-skill'); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toContain( + 'body of prompt-skill', + ); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain( + '', + ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); }); @@ -94,7 +105,10 @@ describe('SkillTool dispatch edges', () => { expect(result.output).toContain('loaded inline'); expect(result.output).not.toContain('body of legacy'); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain('body of legacy'); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toContain('body of legacy'); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain( + '', + ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); }); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index 99ef0440c..63224b1ed 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -43,12 +43,17 @@ function registry( interface SkillToolMethods { readonly recordSkillActivation: (origin: SkillActivationOrigin) => void; readonly recordSystemReminder: (content: string, origin: SkillActivationOrigin) => void; + readonly recordUserMessage: ( + content: readonly [{ readonly type: 'text'; readonly text: string }], + origin: SkillActivationOrigin, + ) => void; } function skillToolMethods() { return { recordSkillActivation: vi.fn(), recordSystemReminder: vi.fn(), + recordUserMessage: vi.fn(), } satisfies SkillToolMethods; } @@ -60,6 +65,7 @@ function skillToolAgent(skills: SkillRegistry, methods: SkillToolMethods): Agent }, context: { appendSystemReminder: methods.recordSystemReminder, + appendUserMessage: methods.recordUserMessage, }, } as unknown as Agent; } @@ -127,7 +133,7 @@ describe('SkillTool execution', () => { expect(methods.recordSkillActivation).not.toHaveBeenCalled(); }); - it('records inline skill content as a system reminder', async () => { + it('records inline skill content as a loaded skill message', async () => { const methods = skillToolMethods(); const tool = skillTool(registry([skill('commit')]), methods); @@ -137,9 +143,13 @@ describe('SkillTool execution', () => { expect(result.output).toContain('loaded inline'); expect(result.output).not.toContain('body of commit'); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); - expect(methods.recordSystemReminder).toHaveBeenCalledTimes(1); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nbody of commit\n\nARGUMENTS: message text\n', + expect(methods.recordUserMessage).toHaveBeenCalledTimes(1); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( + 'Skill tool loaded instructions for this request. Follow them.\n\n' + + '\nbody of commit\n\nARGUMENTS: message text\n', + ); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain( + '', ); }); @@ -161,8 +171,9 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'brainstorming' }); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\n' + + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( + 'Skill tool loaded instructions for this request. Follow them.\n\n' + + '\n' + '\n' + 'Use AskUserQuestion for clarifying questions.\n' + '\n\nbrainstorm body\n' + @@ -185,10 +196,11 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'commit', args: '-m "fix login"' }); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( + 'Skill tool loaded instructions for this request. Follow them.\n\n' + + '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', ); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).not.toContain('ARGUMENTS:'); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain('ARGUMENTS:'); }); it('expands session id from the skill registry for model-invoked skills', async () => { @@ -202,8 +214,9 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'session-aware' }); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nSession: ses_model_skill\n', + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( + 'Skill tool loaded instructions for this request. Follow them.\n\n' + + '\nSession: ses_model_skill\n', ); }); @@ -235,8 +248,9 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'a&b', args: '' }); - expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( + 'Skill tool loaded instructions for this request. Follow them.\n\n' + + '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); }); @@ -254,6 +268,9 @@ describe('SkillTool execution', () => { trigger: 'nested-skill', }), ); + expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toContain( + 'trigger="nested-skill"', + ); }); }); diff --git a/packages/node-sdk/test/session-skills.test.ts b/packages/node-sdk/test/session-skills.test.ts index 1cdefabac..392424716 100644 --- a/packages/node-sdk/test/session-skills.test.ts +++ b/packages/node-sdk/test/session-skills.test.ts @@ -184,7 +184,15 @@ describe('Session skills', () => { input: [ { type: 'text', - text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', + text: [ + 'User activated the skill "review". Follow the loaded skill instructions.', + '', + '', + 'Review the requested file.', + '', + 'ARGUMENTS: src/app.ts', + '', + ].join('\n'), }, ], origin: { From b747c6a9501e208250d09cf9a2810c885c6ce91b Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Wed, 10 Jun 2026 13:15:24 +0800 Subject: [PATCH 015/476] feat(kosong): support claude-fable-5 with adaptive thinking (#610) * feat(kosong): support claude-fable-5 adaptive thinking claude-fable-5 only accepts thinking: {type: "adaptive"} with output_config.effort; the legacy enabled/budget_tokens config and an explicit disabled config both return HTTP 400. - Parse the fable family in Claude model ids (major-only version) - Route fable >= 5 to adaptive thinking; allow xhigh effort - Omit the thinking field entirely when thinking is off on fable - Register the 128k output ceiling and thinking/vision capability * update .changeset Updated the configuration to use adaptive thinking for Claude Fable 5 support. Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn> --------- Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn> --- .changeset/anthropic-fable-5.md | 6 ++++ packages/kosong/src/providers/anthropic.ts | 30 ++++++++++++----- .../src/providers/capability-registry.ts | 15 ++++++--- packages/kosong/test/anthropic.test.ts | 32 +++++++++++++++++++ .../kosong/test/capability-providers.test.ts | 7 ++++ 5 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 .changeset/anthropic-fable-5.md diff --git a/.changeset/anthropic-fable-5.md b/.changeset/anthropic-fable-5.md new file mode 100644 index 000000000..93b8099c1 --- /dev/null +++ b/.changeset/anthropic-fable-5.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": minor +"@moonshot-ai/kimi-code": minor +--- + +Add Claude Fable 5 support to the Anthropic provider. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 29c1b3171..9bdbc15dd 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -132,6 +132,8 @@ const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { * family's baseline rather than the generic fallback. */ const CEILING_BY_FAMILY_VERSION: Readonly> = { + // Claude Fable 5 documents a 128k output ceiling. + 'fable-5': 128000, // Claude Opus per minor version. 4.6 and 4.7 raised the cap to 128k; // 4.5 ships at 64k; 4.1 and the dated 4.0 release stay at 32k. 'opus-4-7': 128000, @@ -162,7 +164,7 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { const FALLBACK_MAX_TOKENS = 32000; -type ClaudeFamily = 'opus' | 'sonnet' | 'haiku'; +type ClaudeFamily = 'opus' | 'sonnet' | 'haiku' | 'fable'; interface ClaudeVersion { family: ClaudeFamily; @@ -170,12 +172,13 @@ interface ClaudeVersion { minor: number | null; } -// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001". +// Family-first form: "opus-4-7", "sonnet-4.6", "haiku-4-5-20251001", +// "fable-5" (single version component — Fable ids carry no minor). // Version numbers are capped at 1–2 digits with a non-digit lookahead so // 8-digit date suffixes (e.g. `-20251001`) don't get consumed as version // components. const FAMILY_FIRST_RE = - /(opus|sonnet|haiku)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; + /(opus|sonnet|haiku|fable)[-._](\d{1,2})(?!\d)(?:[-._](\d{1,2})(?!\d))?/; // Legacy version-first form: "3-5-sonnet", "3.7.opus" — used by older // Anthropic model ids and Bedrock variants of Claude 3.x. const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; @@ -292,11 +295,13 @@ function versionAtLeast( function supportsAdaptiveThinking(model: string): boolean { const version = parseClaudeAliasVersion(model); - if (version === null || version.minor === null) { + if (version === null) { return false; } + // A missing minor is a bare family-major id: "claude-fable-5" (5.0 ≥ 4.6, + // adaptive-only) or "claude-opus-4" (4.0 < 4.6, budget-based). return versionAtLeast( - { major: version.major, minor: version.minor }, + { major: version.major, minor: version.minor ?? 0 }, ADAPTIVE_MIN_VERSION, ); } @@ -310,6 +315,10 @@ function isOpus47(model: string): boolean { return version.major === 4 && version.minor === 7; } +function isFableModel(model: string): boolean { + return parseClaudeAliasVersion(model)?.family === 'fable'; +} + function supportsEffortParam(model: string, adaptive: boolean): boolean { if (adaptive) { return true; @@ -322,7 +331,7 @@ function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): if (effort === 'off') { return effort; } - if (effort === 'xhigh' && !isOpus47(model)) { + if (effort === 'xhigh' && !isOpus47(model) && !isFableModel(model)) { return 'high'; } if (effort === 'max' && !adaptive) { @@ -962,8 +971,13 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.top_p !== undefined) { kwargs['top_p'] = this._generationKwargs.top_p; } - if (this._generationKwargs.thinking !== undefined) { - kwargs['thinking'] = this._generationKwargs.thinking; + // Fable rejects an explicit `disabled` thinking config (HTTP 400, unlike + // Opus 4.7/4.8 which accept it), so omit the field instead. Note thinking + // cannot actually be turned off on Fable: adaptive thinking is always on, + // and an omitted `thinking` field still runs with it. + const thinking = this._generationKwargs.thinking; + if (thinking !== undefined && !(thinking.type === 'disabled' && isFableModel(this._model))) { + kwargs['thinking'] = thinking; } if (this._generationKwargs.output_config !== undefined) { kwargs['output_config'] = this._generationKwargs.output_config; diff --git a/packages/kosong/src/providers/capability-registry.ts b/packages/kosong/src/providers/capability-registry.ts index 17ec9d6bd..c5f415859 100644 --- a/packages/kosong/src/providers/capability-registry.ts +++ b/packages/kosong/src/providers/capability-registry.ts @@ -28,12 +28,19 @@ const OPENAI_VISION_TOOL_PREFIXES = [ 'gpt-4.5', ] as const; -const CLAUDE_3_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; +// Claude prefixes are grouped by capability set, not by version family: +// a new model joins the group whose capability it matches (e.g. Fable sits +// with Opus/Sonnet/Haiku 4), rather than getting a per-version group. -const CLAUDE_4_PREFIXES = [ +// Vision + tool use, no thinking (-> ANTHROPIC_VISION_TOOL_CAPABILITY). +const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; + +// Vision + tool use + thinking (-> ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY). +const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ 'claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4', + 'claude-fable', ] as const; const GEMINI_CATALOGUED_PREFIXES = [ @@ -136,11 +143,11 @@ const OPENAI_RESPONSES_CAPABILITY_CATALOG: readonly CapabilityCatalogEntry[] = [ const ANTHROPIC_CAPABILITY_CATALOG: readonly CapabilityCatalogEntry[] = [ { - matches: (name) => hasPrefix(name, CLAUDE_3_PREFIXES), + matches: (name) => hasPrefix(name, CLAUDE_VISION_TOOL_PREFIXES), capability: ANTHROPIC_VISION_TOOL_CAPABILITY, }, { - matches: (name) => hasPrefix(name, CLAUDE_4_PREFIXES), + matches: (name) => hasPrefix(name, CLAUDE_THINKING_VISION_TOOL_PREFIXES), capability: ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY, }, ]; diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index dcf2ef4ff..fe6f2e29c 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1005,6 +1005,31 @@ describe('AnthropicChatProvider', () => { expect(body['output_config']).toEqual({ effort: 'xhigh' }); }); + it('claude-fable-5: uses adaptive thinking with xhigh effort', async () => { + const provider = createProvider('claude-fable-5').withThinking('xhigh'); + const body = await captureRequestBody(provider, '', [], thinkHistory); + + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); + // Adaptive should remove interleaved-thinking beta + const headers = body['_extra_headers'] as Record | undefined; + if (headers !== undefined && headers['anthropic-beta'] !== undefined) { + expect(headers['anthropic-beta']).not.toContain('interleaved-thinking-2025-05-14'); + } + }); + + it('claude-fable-5 with thinking off omits the thinking field entirely', async () => { + // Fable 400s on an explicit `disabled` thinking config (unlike Opus + // 4.7/4.8); the provider must drop the field from the request while + // still reporting `off` to callers. + const provider = createProvider('claude-fable-5').withThinking('off'); + expect(provider.thinkingEffort).toBe('off'); + + const body = await captureRequestBody(provider, '', [], thinkHistory); + expect('thinking' in body).toBe(false); + expect(body['output_config']).toBeUndefined(); + }); + it.each([ 'claude-sonnet-4-6', 'anthropic.claude-opus-4-7-v1:0', @@ -1289,6 +1314,9 @@ describe('AnthropicChatProvider', () => { 'claude-sonnet-5-0', 'claude-haiku-4-6', 'claude-haiku-5-0', + // Fable (major-only version, no minor) + 'claude-fable-5', + 'anthropic.claude-fable-5-v1:0', // Bedrock / Vertex / proxy prefixes 'anthropic.claude-opus-4-7-v1:0', 'aws/claude-opus-4-7', @@ -1352,6 +1380,9 @@ describe('AnthropicChatProvider', () => { ['claude-sonnet-4-6', 'xhigh', 'high'], // low/medium/high passthrough ['claude-opus-4-6', 'medium', 'medium'], + // Fable 5: full range including xhigh and max + ['claude-fable-5', 'xhigh', 'xhigh'], + ['claude-fable-5', 'max', 'max'], // Future 4.8+: inherits max but xhigh clamps to high ['claude-opus-4-8', 'xhigh', 'high'], ['claude-opus-4-8', 'max', 'max'], @@ -2108,6 +2139,7 @@ describe('AnthropicChatProvider', () => { describe('resolveDefaultMaxTokens', () => { it('returns per-version Messages-API caps for known Claude 4 models', () => { + expect(resolveDefaultMaxTokens('claude-fable-5')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-7')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-6')).toBe(128000); expect(resolveDefaultMaxTokens('claude-opus-4-5-20251101')).toBe(64000); diff --git a/packages/kosong/test/capability-providers.test.ts b/packages/kosong/test/capability-providers.test.ts index 55f26d979..4de101dc6 100644 --- a/packages/kosong/test/capability-providers.test.ts +++ b/packages/kosong/test/capability-providers.test.ts @@ -114,6 +114,13 @@ describe('AnthropicChatProvider.getCapability', () => { expect(cap.tool_use).toBe(true); }); + it('claude-fable-5 → image_in + thinking + tool_use', () => { + const cap = make('claude-fable-5').getCapability(); + expect(cap.image_in).toBe(true); + expect(cap.thinking).toBe(true); + expect(cap.tool_use).toBe(true); + }); + it('no Anthropic model supports audio_in', () => { // Sanity: Anthropic has no audio-input models today. If one ships later // and this fails, update the table — but make it a conscious decision. From 95a124804ea31227f2ba29df318f4c36b424e5a2 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Wed, 10 Jun 2026 13:54:27 +0800 Subject: [PATCH 016/476] chore: downgrade Fable 5 changeset to patch (#614) --- .changeset/anthropic-fable-5.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/anthropic-fable-5.md b/.changeset/anthropic-fable-5.md index 93b8099c1..1387c31ef 100644 --- a/.changeset/anthropic-fable-5.md +++ b/.changeset/anthropic-fable-5.md @@ -1,6 +1,6 @@ --- -"@moonshot-ai/kosong": minor -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch --- Add Claude Fable 5 support to the Anthropic provider. From 4603d8ad6e92a303f396f3d79d4e4d212d1c4b14 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 10 Jun 2026 14:03:38 +0800 Subject: [PATCH 017/476] feat(protocol): extract shared protocol package from agent-core (#612) * feat(protocol): extract shared protocol package from agent-core - add `@moonshot-ai/protocol` package with REST/WS schemas, envelopes, error codes, event types, and display schemas\n- migrate agent-core `events.ts` and `display/schemas.ts` to re-export from protocol - add centralized `onUnexpectedError` handler for safe emitter listener callbacks - reject forkSession when source session has an active running turn - add protocol schema tests and unexpectedError handler tests --- .changeset/protocol-and-fork-guard.md | 7 + flake.nix | 4 +- packages/agent-core/package.json | 1 + packages/agent-core/src/errors/index.ts | 7 + .../agent-core/src/errors/unexpectedError.ts | 73 +++ packages/agent-core/src/rpc/core-impl.ts | 8 + packages/agent-core/src/rpc/events.ts | 376 ++---------- .../agent-core/src/tools/display/schemas.ts | 173 +----- .../test/errors/unexpectedError.test.ts | 81 +++ packages/node-sdk/test/session-cancel.test.ts | 34 ++ packages/protocol/package.json | 39 ++ .../protocol/src/__tests__/approval.test.ts | 176 ++++++ .../protocol/src/__tests__/envelope.test.ts | 95 +++ .../protocol/src/__tests__/events.test.ts | 52 ++ packages/protocol/src/__tests__/file.test.ts | 51 ++ packages/protocol/src/__tests__/fs.test.ts | 332 ++++++++++ .../protocol/src/__tests__/message.test.ts | 148 +++++ .../src/__tests__/model-catalog.test.ts | 70 +++ .../protocol/src/__tests__/pagination.test.ts | 75 +++ .../protocol/src/__tests__/question.test.ts | 287 +++++++++ .../protocol/src/__tests__/request-id.test.ts | 61 ++ .../protocol/src/__tests__/rest-auth.test.ts | 94 +++ .../protocol/src/__tests__/rest-file.test.ts | 56 ++ .../src/__tests__/rest-fs-browse.test.ts | 101 ++++ .../protocol/src/__tests__/rest-fs.test.ts | 430 +++++++++++++ .../src/__tests__/rest-message.test.ts | 78 +++ .../protocol/src/__tests__/rest-meta.test.ts | 73 +++ .../src/__tests__/rest-prompt.test.ts | 181 ++++++ .../src/__tests__/rest-session.test.ts | 422 +++++++++++++ .../protocol/src/__tests__/rest-task.test.ts | 73 +++ .../protocol/src/__tests__/rest-tool.test.ts | 72 +++ .../src/__tests__/rest-workspace.test.ts | 150 +++++ .../protocol/src/__tests__/session.test.ts | 218 +++++++ packages/protocol/src/__tests__/task.test.ts | 72 +++ packages/protocol/src/__tests__/time.test.ts | 50 ++ packages/protocol/src/__tests__/tool.test.ts | 100 +++ .../protocol/src/__tests__/ws-control.test.ts | 317 ++++++++++ packages/protocol/src/approval.ts | 30 + packages/protocol/src/display.ts | 153 +++++ packages/protocol/src/envelope.ts | 26 + packages/protocol/src/error-codes.ts | 180 ++++++ packages/protocol/src/events.ts | 572 ++++++++++++++++++ packages/protocol/src/file.ts | 13 + packages/protocol/src/fs.ts | 88 +++ packages/protocol/src/index.ts | 35 ++ packages/protocol/src/message.ts | 84 +++ packages/protocol/src/modelCatalog.ts | 28 + packages/protocol/src/pagination.ts | 35 ++ packages/protocol/src/question.ts | 57 ++ packages/protocol/src/request-id.ts | 14 + packages/protocol/src/rest/approval.ts | 43 ++ packages/protocol/src/rest/auth.ts | 32 + packages/protocol/src/rest/file.ts | 36 ++ packages/protocol/src/rest/fs.ts | 207 +++++++ packages/protocol/src/rest/fsBrowse.ts | 41 ++ packages/protocol/src/rest/message.ts | 31 + packages/protocol/src/rest/meta.ts | 31 + packages/protocol/src/rest/modelCatalog.ts | 25 + packages/protocol/src/rest/oauth.ts | 73 +++ packages/protocol/src/rest/prompt.ts | 119 ++++ packages/protocol/src/rest/question.ts | 48 ++ packages/protocol/src/rest/session.ts | 124 ++++ packages/protocol/src/rest/task.ts | 46 ++ packages/protocol/src/rest/tool.ts | 38 ++ packages/protocol/src/rest/workspace.ts | 47 ++ packages/protocol/src/session.ts | 133 ++++ packages/protocol/src/task.ts | 28 + packages/protocol/src/time.ts | 29 + packages/protocol/src/tool.ts | 34 ++ packages/protocol/src/workspace.ts | 37 ++ packages/protocol/src/ws-control.ts | 227 +++++++ packages/protocol/tsconfig.json | 7 + packages/protocol/tsdown.config.ts | 13 + packages/protocol/vitest.config.ts | 11 + pnpm-lock.yaml | 18 + 75 files changed, 6934 insertions(+), 496 deletions(-) create mode 100644 .changeset/protocol-and-fork-guard.md create mode 100644 packages/agent-core/src/errors/unexpectedError.ts create mode 100644 packages/agent-core/test/errors/unexpectedError.test.ts create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/__tests__/approval.test.ts create mode 100644 packages/protocol/src/__tests__/envelope.test.ts create mode 100644 packages/protocol/src/__tests__/events.test.ts create mode 100644 packages/protocol/src/__tests__/file.test.ts create mode 100644 packages/protocol/src/__tests__/fs.test.ts create mode 100644 packages/protocol/src/__tests__/message.test.ts create mode 100644 packages/protocol/src/__tests__/model-catalog.test.ts create mode 100644 packages/protocol/src/__tests__/pagination.test.ts create mode 100644 packages/protocol/src/__tests__/question.test.ts create mode 100644 packages/protocol/src/__tests__/request-id.test.ts create mode 100644 packages/protocol/src/__tests__/rest-auth.test.ts create mode 100644 packages/protocol/src/__tests__/rest-file.test.ts create mode 100644 packages/protocol/src/__tests__/rest-fs-browse.test.ts create mode 100644 packages/protocol/src/__tests__/rest-fs.test.ts create mode 100644 packages/protocol/src/__tests__/rest-message.test.ts create mode 100644 packages/protocol/src/__tests__/rest-meta.test.ts create mode 100644 packages/protocol/src/__tests__/rest-prompt.test.ts create mode 100644 packages/protocol/src/__tests__/rest-session.test.ts create mode 100644 packages/protocol/src/__tests__/rest-task.test.ts create mode 100644 packages/protocol/src/__tests__/rest-tool.test.ts create mode 100644 packages/protocol/src/__tests__/rest-workspace.test.ts create mode 100644 packages/protocol/src/__tests__/session.test.ts create mode 100644 packages/protocol/src/__tests__/task.test.ts create mode 100644 packages/protocol/src/__tests__/time.test.ts create mode 100644 packages/protocol/src/__tests__/tool.test.ts create mode 100644 packages/protocol/src/__tests__/ws-control.test.ts create mode 100644 packages/protocol/src/approval.ts create mode 100644 packages/protocol/src/display.ts create mode 100644 packages/protocol/src/envelope.ts create mode 100644 packages/protocol/src/error-codes.ts create mode 100644 packages/protocol/src/events.ts create mode 100644 packages/protocol/src/file.ts create mode 100644 packages/protocol/src/fs.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/message.ts create mode 100644 packages/protocol/src/modelCatalog.ts create mode 100644 packages/protocol/src/pagination.ts create mode 100644 packages/protocol/src/question.ts create mode 100644 packages/protocol/src/request-id.ts create mode 100644 packages/protocol/src/rest/approval.ts create mode 100644 packages/protocol/src/rest/auth.ts create mode 100644 packages/protocol/src/rest/file.ts create mode 100644 packages/protocol/src/rest/fs.ts create mode 100644 packages/protocol/src/rest/fsBrowse.ts create mode 100644 packages/protocol/src/rest/message.ts create mode 100644 packages/protocol/src/rest/meta.ts create mode 100644 packages/protocol/src/rest/modelCatalog.ts create mode 100644 packages/protocol/src/rest/oauth.ts create mode 100644 packages/protocol/src/rest/prompt.ts create mode 100644 packages/protocol/src/rest/question.ts create mode 100644 packages/protocol/src/rest/session.ts create mode 100644 packages/protocol/src/rest/task.ts create mode 100644 packages/protocol/src/rest/tool.ts create mode 100644 packages/protocol/src/rest/workspace.ts create mode 100644 packages/protocol/src/session.ts create mode 100644 packages/protocol/src/task.ts create mode 100644 packages/protocol/src/time.ts create mode 100644 packages/protocol/src/tool.ts create mode 100644 packages/protocol/src/workspace.ts create mode 100644 packages/protocol/src/ws-control.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/protocol/tsdown.config.ts create mode 100644 packages/protocol/vitest.config.ts diff --git a/.changeset/protocol-and-fork-guard.md b/.changeset/protocol-and-fork-guard.md new file mode 100644 index 000000000..4d8d2e0bf --- /dev/null +++ b/.changeset/protocol-and-fork-guard.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/protocol": minor +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. diff --git a/flake.nix b/flake.nix index 42b7e61bc..45ae4dcff 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,7 @@ ./packages/migration-legacy ./packages/node-sdk ./packages/oauth + ./packages/protocol ./packages/telemetry ./apps/kimi-code ./apps/vis @@ -85,6 +86,7 @@ "@moonshot-ai/migration-legacy" "@moonshot-ai/kimi-code-sdk" "@moonshot-ai/kimi-code-oauth" + "@moonshot-ai/protocol" "@moonshot-ai/kimi-telemetry" "@moonshot-ai/kimi-code" "@moonshot-ai/vis" @@ -140,7 +142,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-x5O/+bDRoEW3juoxe3XyvTJxHitQ0hZ2nVXBItkBA5E="; + hash = "sha256-XwkLwxWZtOaw1N1GKR9G3z0yhXO/lDB5+O+VKtgxKWo="; }; nativeBuildInputs = [ diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 3b8aea5cb..ccec3267e 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -58,6 +58,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kosong": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", "@mozilla/readability": "^0.6.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", diff --git a/packages/agent-core/src/errors/index.ts b/packages/agent-core/src/errors/index.ts index 317c7f3c5..66379cbe4 100644 --- a/packages/agent-core/src/errors/index.ts +++ b/packages/agent-core/src/errors/index.ts @@ -15,3 +15,10 @@ export { toKimiErrorPayload, type KimiErrorPayload, } from './serialize'; +export { + onUnexpectedError, + resetUnexpectedErrorHandler, + safelyCallListener, + setUnexpectedErrorHandler, + type UnexpectedErrorHandler, +} from './unexpectedError'; diff --git a/packages/agent-core/src/errors/unexpectedError.ts b/packages/agent-core/src/errors/unexpectedError.ts new file mode 100644 index 000000000..eb65b6743 --- /dev/null +++ b/packages/agent-core/src/errors/unexpectedError.ts @@ -0,0 +1,73 @@ +/** + * Centralised reporting for unexpected, non-actionable errors. The pattern: listener + * callbacks (registered via `Emitter.event(...)`) may throw; the Emitter + * routes those exceptions through `onUnexpectedError` rather than swallowing + * them silently or letting them bubble through `fire()`. + * + * **Startup-timing constraint (plan §4.5)**: the default handler intentionally + * does NOT resolve `ILogService` at module-load time. At import time the DI + * container is empty — touching `ILogService` would NPE. Instead, the default + * stays as a plain `console.error` until the daemon's `startDaemon` later + * calls `setUnexpectedErrorHandler(...)` with a logger-bound version (once + * `ILogger` has been resolved from the accessor). Until that handoff, + * exceptions routed here surface on stderr — visible but unstructured. + */ + +export type UnexpectedErrorHandler = (err: unknown) => void; + +/** + * Default handler. NOTE: do not touch `ILogService` here — this module is + * imported eagerly and the DI container has no logger registered at module- + * load time. Falling back to `console.error` keeps startup safe. + */ +const defaultHandler: UnexpectedErrorHandler = (err) => { + // eslint-disable-next-line no-console + console.error('[unexpected]', err); +}; + +let currentHandler: UnexpectedErrorHandler = defaultHandler; + +/** + * Install a new global handler. Replaces any previously-installed handler. + * `startDaemon` calls this once after the DI container is fully wired so + * later exceptions route through `ILogService` instead of stderr. + */ +export function setUnexpectedErrorHandler(handler: UnexpectedErrorHandler): void { + currentHandler = handler; +} + +/** + * Reset the global handler to the module-default `console.error` handler. + * Primarily used by tests so a handler installed by one test does not leak + * into the next. + */ +export function resetUnexpectedErrorHandler(): void { + currentHandler = defaultHandler; +} + +/** + * Report an unexpected error through the currently-installed handler. The + * handler itself MUST NOT throw; if it does, we fall back to `console.error` + * so a single broken handler does not silently lose the original error. + */ +export function onUnexpectedError(err: unknown): void { + try { + currentHandler(err); + } catch (handlerErr) { + // eslint-disable-next-line no-console + console.error('[unexpected] handler threw', handlerErr, 'while reporting', err); + } +} + +/** + * Helper used by `Emitter.fire()` to safely invoke a single listener: any + * synchronous exception is routed through `onUnexpectedError` so siblings + * still run and listener failures don't propagate up the `fire()` call site. + */ +export function safelyCallListener(listener: () => void): void { + try { + listener(); + } catch (err) { + onUnexpectedError(err); + } +} diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index acd2888a1..86db94e96 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -379,6 +379,14 @@ export class KimiCore implements PromisableMethods { async forkSession(input: ForkSessionPayload): Promise { const source = await this.sessionStore.get(input.sessionId); const active = this.sessions.get(source.id); + if (active?.hasActiveTurn === true) { + throw new KimiError( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${source.id}" cannot be forked while a turn is running`, + { details: { sessionId: source.id } }, + ); + } + if (active !== undefined) { await active.flushMetadata(); } diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index 69633d004..f2e82fb42 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -1,333 +1,49 @@ -import type { FinishReason, TokenUsage } from '@moonshot-ai/kosong'; +export { MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE } from '@moonshot-ai/protocol'; -import type { GoalChange, GoalSnapshot } from '../agent/goal'; -import type { CronJobOrigin, PromptOrigin } from '../agent/context'; -import type { KimiErrorPayload } from '../errors'; -import type { PermissionMode } from '../agent/permission'; -import type { SkillSource } from '../skill'; -import type { BackgroundTaskInfo } from '../agent/background'; -import type { ToolInputDisplay } from '../tools/display'; +export type { + AgentEvent, + AgentStatusUpdatedEvent, + AssistantDeltaEvent, + BackgroundTaskStartedEvent, + BackgroundTaskTerminatedEvent, + CompactionBlockedEvent, + CompactionCancelledEvent, + CompactionCompletedEvent, + CompactionResult, + CompactionStartedEvent, + CronFiredEvent, + ErrorEvent, + Event, + GoalUpdatedEvent, + HookResultEvent, + McpOAuthAuthorizationUrlUpdateData, + McpServerStatusEvent, + McpServerStatusPayload, + SessionMetaUpdatedEvent, + SkillActivatedEvent, + SubagentCompletedEvent, + SubagentFailedEvent, + SubagentSpawnedEvent, + SubagentStartedEvent, + SubagentSuspendedEvent, + ThinkingDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolInputDisplay, + ToolListUpdatedEvent, + ToolListUpdatedReason, + ToolProgressEvent, + ToolResultEvent, + ToolUpdate, + TurnEndedEvent, + TurnEndReason, + TurnStartedEvent, + TurnStepCompletedEvent, + TurnStepInterruptedEvent, + TurnStepRetryingEvent, + TurnStepStartedEvent, + UsageStatus, + WarningEvent, +} from '@moonshot-ai/protocol'; -export type { ToolInputDisplay } from '../tools/display'; export type { KimiErrorPayload } from '../errors'; - -export interface UsageStatus { - readonly byModel?: Record | undefined; - readonly currentTurn?: TokenUsage | undefined; - readonly total?: TokenUsage | undefined; -} - -export interface ToolUpdate { - readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; - readonly text?: string | undefined; - readonly percent?: number | undefined; - readonly customKind?: string | undefined; - readonly customData?: unknown; -} - -export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url'; - -export interface McpOAuthAuthorizationUrlUpdateData { - readonly serverName: string; - readonly authorizationUrl: string; -} - -export interface CompactionResult { - readonly summary: string; - readonly compactedCount: number; - readonly tokensBefore: number; - readonly tokensAfter: number; -} - -export type TurnEndReason = 'completed' | 'cancelled' | 'failed'; - -export interface AgentStatusUpdatedEvent { - readonly type: 'agent.status.updated'; - readonly model?: string | undefined; - readonly contextTokens?: number | undefined; - readonly maxContextTokens?: number | undefined; - readonly contextUsage?: number | undefined; - readonly planMode?: boolean | undefined; - readonly swarmMode?: boolean | undefined; - readonly permission?: PermissionMode | undefined; - readonly usage?: UsageStatus | undefined; -} - -export interface SessionMetaUpdatedEvent { - readonly type: 'session.meta.updated'; - readonly title?: string | undefined; - readonly patch?: Record | undefined; -} - -export interface GoalUpdatedEvent { - readonly type: 'goal.updated'; - /** Current goal snapshot, or `null` when no goal is set (cleared/cancelled). */ - readonly snapshot: GoalSnapshot | null; - /** - * What changed, when the update is a lifecycle / verdict / terminal transition. - * Absent for snapshot-only refreshes (e.g. a turn increment). Drives transcript - * markers and the completion card. - */ - readonly change?: GoalChange; -} - -export interface SkillActivatedEvent { - readonly type: 'skill.activated'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string | undefined; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillPath?: string | undefined; - readonly skillSource?: SkillSource | undefined; -} - -export interface ErrorEvent extends KimiErrorPayload { - readonly type: 'error'; -} - -export interface WarningEvent { - readonly type: 'warning'; - readonly message: string; - readonly code?: string | undefined; -} - -export interface TurnStartedEvent { - readonly type: 'turn.started'; - readonly turnId: number; - readonly origin: PromptOrigin; -} - -export interface TurnEndedEvent { - readonly type: 'turn.ended'; - readonly turnId: number; - readonly reason: TurnEndReason; - readonly error?: KimiErrorPayload | undefined; -} - -export interface TurnStepStartedEvent { - readonly type: 'turn.step.started'; - readonly turnId: number; - readonly step: number; - readonly stepId?: string | undefined; -} - -export interface TurnStepCompletedEvent { - readonly type: 'turn.step.completed'; - readonly turnId: number; - readonly step: number; - readonly stepId?: string | undefined; - readonly usage?: TokenUsage | undefined; - readonly finishReason?: string | undefined; - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly providerFinishReason?: FinishReason | undefined; - readonly rawFinishReason?: string | undefined; -} - -export interface TurnStepRetryingEvent { - readonly type: 'turn.step.retrying'; - readonly turnId: number; - readonly step: number; - readonly stepId?: string; - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName: string; - readonly errorMessage: string; - readonly statusCode?: number; -} - -export interface TurnStepInterruptedEvent { - readonly type: 'turn.step.interrupted'; - readonly turnId: number; - readonly step: number; - readonly stepId?: string | undefined; - readonly reason: string; - readonly message?: string | undefined; -} - -export interface AssistantDeltaEvent { - readonly type: 'assistant.delta'; - readonly turnId: number; - readonly delta: string; -} - -export interface HookResultEvent { - readonly type: 'hook.result'; - readonly turnId: number; - readonly hookEvent: string; - readonly content: string; - readonly blocked?: boolean | undefined; -} - -export interface ThinkingDeltaEvent { - readonly type: 'thinking.delta'; - readonly turnId: number; - readonly delta: string; -} - -export interface ToolCallDeltaEvent { - readonly type: 'tool.call.delta'; - readonly turnId: number; - readonly toolCallId: string; - readonly name?: string | undefined; - readonly argumentsPart?: string | undefined; -} - -export interface ToolCallStartedEvent { - readonly type: 'tool.call.started'; - readonly turnId: number; - readonly toolCallId: string; - readonly name: string; - readonly args: unknown; - readonly description?: string | undefined; - readonly display?: ToolInputDisplay | undefined; -} - -export interface ToolProgressEvent { - readonly type: 'tool.progress'; - readonly turnId: number; - readonly toolCallId: string; - readonly update: ToolUpdate; -} - -export interface ToolResultEvent { - readonly type: 'tool.result'; - readonly turnId: number; - readonly toolCallId: string; - readonly output: unknown; - readonly isError?: boolean | undefined; - readonly synthetic?: boolean | undefined; -} - -export interface SubagentSpawnedEvent { - readonly type: 'subagent.spawned'; - readonly subagentId: string; - readonly subagentName: string; - readonly parentToolCallId: string; - readonly parentToolCallUuid?: string | undefined; - readonly parentAgentId?: string | undefined; - readonly description?: string | undefined; - readonly swarmIndex?: number; - readonly runInBackground: boolean; -} - -export interface SubagentStartedEvent { - readonly type: 'subagent.started'; - readonly subagentId: string; -} - -export interface SubagentSuspendedEvent { - readonly type: 'subagent.suspended'; - readonly subagentId: string; - readonly reason: string; -} - -export interface SubagentCompletedEvent { - readonly type: 'subagent.completed'; - readonly subagentId: string; - readonly resultSummary: string; - readonly usage?: TokenUsage | undefined; - readonly contextTokens?: number | undefined; -} - -export interface SubagentFailedEvent { - readonly type: 'subagent.failed'; - readonly subagentId: string; - readonly error: string; -} - -export interface CompactionStartedEvent { - readonly type: 'compaction.started'; - readonly trigger: 'manual' | 'auto'; - readonly instruction?: string | undefined; -} - -export interface CompactionBlockedEvent { - readonly type: 'compaction.blocked'; - readonly turnId?: number | undefined; -} - -export interface CompactionCancelledEvent { - readonly type: 'compaction.cancelled'; -} - -export interface CompactionCompletedEvent { - readonly type: 'compaction.completed'; - readonly result: CompactionResult; -} - -export interface BackgroundTaskStartedEvent { - readonly type: 'background.task.started'; - readonly info: BackgroundTaskInfo; -} - -export interface BackgroundTaskTerminatedEvent { - readonly type: 'background.task.terminated'; - readonly info: BackgroundTaskInfo; -} - -export interface CronFiredEvent { - readonly type: 'cron.fired'; - readonly origin: CronJobOrigin; - readonly prompt: string; -} - -export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; - -export interface ToolListUpdatedEvent { - readonly type: 'tool.list.updated'; - readonly reason: ToolListUpdatedReason; - readonly serverName: string; -} - -export interface McpServerStatusEvent { - readonly type: 'mcp.server.status'; - readonly server: McpServerStatusPayload; -} - -export interface McpServerStatusPayload { - readonly name: string; - readonly transport: 'stdio' | 'http'; - readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; - readonly toolCount: number; - readonly error?: string | undefined; -} - -export type AgentEvent = - | ErrorEvent - | WarningEvent - | AgentStatusUpdatedEvent - | SessionMetaUpdatedEvent - | GoalUpdatedEvent - | SkillActivatedEvent - | TurnStartedEvent - | TurnEndedEvent - | TurnStepStartedEvent - | TurnStepCompletedEvent - | TurnStepRetryingEvent - | TurnStepInterruptedEvent - | AssistantDeltaEvent - | HookResultEvent - | ThinkingDeltaEvent - | ToolCallDeltaEvent - | ToolCallStartedEvent - | ToolProgressEvent - | ToolResultEvent - | ToolListUpdatedEvent - | McpServerStatusEvent - | SubagentSpawnedEvent - | SubagentStartedEvent - | SubagentSuspendedEvent - | SubagentCompletedEvent - | SubagentFailedEvent - | CompactionStartedEvent - | CompactionBlockedEvent - | CompactionCancelledEvent - | CompactionCompletedEvent - | BackgroundTaskStartedEvent - | BackgroundTaskTerminatedEvent - | CronFiredEvent; - -export type Event = AgentEvent & { agentId: string; sessionId: string }; diff --git a/packages/agent-core/src/tools/display/schemas.ts b/packages/agent-core/src/tools/display/schemas.ts index aabb582f3..7aba38838 100644 --- a/packages/agent-core/src/tools/display/schemas.ts +++ b/packages/agent-core/src/tools/display/schemas.ts @@ -1,165 +1,8 @@ -/** - * Zod schemas for the display unions. - * - * The wire-record layer validates incoming / outgoing - * `input_display` / `result_display` fields against these schemas. - */ - -import { z } from 'zod'; - -export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('command'), - command: z.string(), - cwd: z.string().optional(), - description: z.string().optional(), - language: z.literal('bash').optional(), - }), - z.object({ - kind: z.literal('file_io'), - operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']), - path: z.string(), - detail: z.string().optional(), - // Optional preview payload for the approval panel. Write attaches the - // full content; Edit attaches the old/new hunk as before/after. UIs that - // want a diff or code preview read these instead of having to re-derive - // them from the raw tool args. - content: z.string().optional(), - before: z.string().optional(), - after: z.string().optional(), - }), - z.object({ - kind: z.literal('diff'), - path: z.string(), - before: z.string(), - after: z.string(), - hunks: z.number().optional(), - }), - z.object({ - kind: z.literal('search'), - query: z.string(), - scope: z.string().optional(), - }), - z.object({ - kind: z.literal('url_fetch'), - url: z.string(), - method: z.string().optional(), - }), - z.object({ - kind: z.literal('agent_call'), - agent_name: z.string(), - prompt: z.string(), - background: z.boolean().optional(), - }), - z.object({ - kind: z.literal('skill_call'), - skill_name: z.string(), - args: z.string().optional(), - }), - z.object({ - kind: z.literal('todo_list'), - items: z.array(z.object({ title: z.string(), status: z.string() })), - }), - z.object({ - kind: z.literal('background_task'), - task_id: z.string(), - status: z.string(), - description: z.string(), - task_kind: z.string().optional(), - }), - z.object({ - kind: z.literal('task_stop'), - task_id: z.string(), - task_description: z.string(), - }), - z.object({ - kind: z.literal('plan_review'), - plan: z.string(), - path: z.string().optional(), - options: z - .array( - z.object({ - label: z.string(), - description: z.string(), - }), - ) - .readonly() - .optional(), - }), - z.object({ - kind: z.literal('generic'), - summary: z.string(), - detail: z.unknown().optional(), - }), -]); - -export const ToolResultDisplaySchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('command_output'), - exit_code: z.number(), - stdout: z.string().optional(), - stderr: z.string().optional(), - }), - z.object({ - kind: z.literal('file_content'), - path: z.string(), - content: z.string(), - range: z.object({ start: z.number(), end: z.number() }).optional(), - truncated: z.boolean().optional(), - }), - z.object({ - kind: z.literal('diff'), - path: z.string(), - before: z.string(), - after: z.string(), - hunks: z.number().optional(), - }), - z.object({ - kind: z.literal('search_results'), - query: z.string(), - matches: z.array(z.object({ file: z.string(), line: z.number(), text: z.string() })), - }), - z.object({ - kind: z.literal('url_content'), - url: z.string(), - status: z.number(), - preview: z.string().optional(), - content_type: z.string().optional(), - }), - z.object({ - kind: z.literal('agent_summary'), - agent_name: z.string(), - result: z.string().optional(), - steps: z.number().optional(), - }), - z.object({ - kind: z.literal('background_task'), - task_id: z.string(), - status: z.string(), - description: z.string(), - }), - z.object({ - kind: z.literal('todo_list'), - items: z.array(z.object({ title: z.string(), status: z.string() })), - }), - z.object({ kind: z.literal('structured'), data: z.unknown() }), - z.object({ - kind: z.literal('text'), - text: z.string(), - truncated: z.boolean().optional(), - }), - z.object({ - kind: z.literal('error'), - message: z.string(), - code: z.string().optional(), - }), - z.object({ - kind: z.literal('generic'), - summary: z.string(), - detail: z.unknown().optional(), - }), -]); - -// Types inferred from schemas — single source of truth. -export type ToolInputDisplay = z.infer; -export type ToolResultDisplay = z.infer; +export { + ToolInputDisplaySchema, + ToolResultDisplaySchema, +} from '@moonshot-ai/protocol'; +export type { + ToolInputDisplay, + ToolResultDisplay, +} from '@moonshot-ai/protocol'; diff --git a/packages/agent-core/test/errors/unexpectedError.test.ts b/packages/agent-core/test/errors/unexpectedError.test.ts new file mode 100644 index 000000000..ca30b85f1 --- /dev/null +++ b/packages/agent-core/test/errors/unexpectedError.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + onUnexpectedError, + resetUnexpectedErrorHandler, + safelyCallListener, + setUnexpectedErrorHandler, +} from '#/errors/unexpectedError'; + +describe('onUnexpectedError + setUnexpectedErrorHandler', () => { + afterEach(() => { + resetUnexpectedErrorHandler(); + }); + + it('default handler does not throw when passed a thrown error', () => { + // Default handler is console.error; replace with a sink so the test + // output stays quiet, but verify the call shape doesn't throw. + let captured: unknown; + setUnexpectedErrorHandler((err) => { + captured = err; + }); + expect(() => onUnexpectedError(new Error('boom'))).not.toThrow(); + expect((captured as Error).message).toBe('boom'); + }); + + it('setUnexpectedErrorHandler replaces the previous handler', () => { + const aSeen: unknown[] = []; + const bSeen: unknown[] = []; + setUnexpectedErrorHandler((err) => aSeen.push(err)); + setUnexpectedErrorHandler((err) => bSeen.push(err)); + onUnexpectedError(new Error('after-replace')); + expect(aSeen).toHaveLength(0); + expect(bSeen).toHaveLength(1); + }); + + it('a throwing handler does NOT propagate; original error is still surfaced', () => { + setUnexpectedErrorHandler(() => { + throw new Error('handler-boom'); + }); + expect(() => onUnexpectedError(new Error('original'))).not.toThrow(); + }); + + it('resetUnexpectedErrorHandler restores the module default', () => { + const seen: unknown[] = []; + setUnexpectedErrorHandler((err) => seen.push(err)); + onUnexpectedError(new Error('with-custom')); + expect(seen).toHaveLength(1); + resetUnexpectedErrorHandler(); + // After reset the custom handler should no longer see further errors. + // We re-install another to verify the custom path is empty. + seen.length = 0; + onUnexpectedError(new Error('after-reset')); + expect(seen).toHaveLength(0); + }); +}); + +describe('safelyCallListener', () => { + afterEach(() => { + resetUnexpectedErrorHandler(); + }); + + it('invokes the listener', () => { + let called = false; + safelyCallListener(() => { + called = true; + }); + expect(called).toBe(true); + }); + + it('routes a thrown error to the installed handler', () => { + const captured: unknown[] = []; + setUnexpectedErrorHandler((err) => captured.push(err)); + expect(() => + safelyCallListener(() => { + throw new Error('listener-boom'); + }), + ).not.toThrow(); + expect(captured).toHaveLength(1); + expect((captured[0] as Error).message).toBe('listener-boom'); + }); +}); diff --git a/packages/node-sdk/test/session-cancel.test.ts b/packages/node-sdk/test/session-cancel.test.ts index efb1ce1c8..49bc3dcee 100644 --- a/packages/node-sdk/test/session-cancel.test.ts +++ b/packages/node-sdk/test/session-cancel.test.ts @@ -119,6 +119,40 @@ describe('Session.cancel', () => { }); }); +describe('KimiHarness.forkSession', () => { + it('rejects while the source session has an active turn', async () => { + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-work-'); + await writeFakeModelConfig(homeDir); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_fork_active_turn', workDir }); + const started = waitForSDKEvent(session, (event) => event.type === 'turn.started'); + const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended'); + + await session.prompt('keep this turn active'); + await started; + try { + await expect( + harness.forkSession({ + id: session.id, + forkId: 'ses_fork_active_child', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'session.fork_active_turn', + } satisfies Partial); + } finally { + await session.cancel().catch(() => undefined); + await ended.catch(() => undefined); + } + } finally { + await harness.close(); + } + }); +}); + async function writeFakeModelConfig(homeDir: string): Promise { await writeFile( join(homeDir, 'config.toml'), diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 000000000..6ccfb8d53 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,39 @@ +{ + "name": "@moonshot-ai/protocol", + "version": "0.1.0", + "private": true, + "description": "Shared REST + WS protocol schemas (envelope, error codes, pagination, ws-control) for the kimi-code daemon.", + "license": "MIT", + "author": "Moonshot AI", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/protocol" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "ulid": "^3.0.1", + "zod": "catalog:" + } +} diff --git a/packages/protocol/src/__tests__/approval.test.ts b/packages/protocol/src/__tests__/approval.test.ts new file mode 100644 index 000000000..133fe446e --- /dev/null +++ b/packages/protocol/src/__tests__/approval.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; + +import { + approvalDecisionSchema, + approvalScopeSchema, + approvalRequestSchema, + approvalResponseSchema, +} from '../approval'; +import { + approvalResolveRequestSchema, + approvalResolveResultSchema, + approvalAlreadyResolvedDataSchema, + listPendingApprovalsQuerySchema, + listPendingApprovalsResponseSchema, +} from '../rest/approval'; + +describe('approvalDecisionSchema (SCHEMAS §6.1)', () => { + it.each(['approved', 'rejected', 'cancelled'] as const)('accepts %s', (d) => { + expect(approvalDecisionSchema.parse(d)).toBe(d); + }); + + it('rejects unknown decision', () => { + expect(() => approvalDecisionSchema.parse('expired')).toThrow(); + }); +}); + +describe('approvalScopeSchema', () => { + it('accepts "session"', () => { + expect(approvalScopeSchema.parse('session')).toBe('session'); + }); + + it('rejects unknown scope', () => { + expect(() => approvalScopeSchema.parse('workspace')).toThrow(); + }); +}); + +describe('approvalRequestSchema (SCHEMAS §6.1)', () => { + const base = { + approval_id: '01J0000000APPROVAL', + session_id: 'sess_x', + tool_call_id: 'tc_1', + tool_name: 'shell.run', + action: 'Run `rm -rf foo/`', + tool_input_display: { kind: 'command', command: 'rm -rf foo/', summary: 'rm' }, + created_at: '2026-06-04T10:30:00Z', + expires_at: '2026-06-04T10:31:00Z', + }; + + it('accepts a full approval request', () => { + const parsed = approvalRequestSchema.parse(base); + expect(parsed.approval_id).toBe('01J0000000APPROVAL'); + expect(parsed.tool_call_id).toBe('tc_1'); + }); + + it('accepts arbitrary tool_input_display shapes (12-arm passthrough)', () => { + const exotic = { ...base, tool_input_display: { kind: 'future_unknown_kind', summary: 'hi' } }; + const parsed = approvalRequestSchema.parse(exotic); + expect((parsed.tool_input_display as { kind: string }).kind).toBe('future_unknown_kind'); + }); + + it('accepts optional turn_id', () => { + const parsed = approvalRequestSchema.parse({ ...base, turn_id: 42 }); + expect(parsed.turn_id).toBe(42); + }); + + it('normalizes timestamps', () => { + const parsed = approvalRequestSchema.parse({ + ...base, + created_at: '2026-06-04T18:30:00+08:00', + }); + expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z'); + }); + + it('rejects missing approval_id', () => { + const { approval_id: _, ...rest } = base; + void _; + expect(() => approvalRequestSchema.parse(rest)).toThrow(); + }); +}); + +describe('approvalResponseSchema (SCHEMAS §6.1)', () => { + it('accepts a minimal approval', () => { + expect(approvalResponseSchema.parse({ decision: 'approved' })).toEqual({ + decision: 'approved', + }); + }); + + it('accepts full response with scope/feedback/selected_label', () => { + const parsed = approvalResponseSchema.parse({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + selected_label: 'Run command', + }); + expect(parsed.scope).toBe('session'); + expect(parsed.feedback).toBe('looks good'); + expect(parsed.selected_label).toBe('Run command'); + }); + + it('rejects unknown decision value', () => { + expect(() => approvalResponseSchema.parse({ decision: 'maybe' })).toThrow(); + }); +}); + +describe('approvalResolveRequestSchema (REST §3.6)', () => { + it('aliases approvalResponseSchema', () => { + const value = approvalResolveRequestSchema.parse({ decision: 'rejected', feedback: 'no' }); + expect(value.decision).toBe('rejected'); + }); +}); + +describe('approvalResolveResultSchema (REST §3.6)', () => { + it('requires resolved:true literal and ISO resolved_at', () => { + const parsed = approvalResolveResultSchema.parse({ + resolved: true, + resolved_at: '2026-06-04T10:31:00Z', + }); + expect(parsed.resolved).toBe(true); + expect(parsed.resolved_at).toBe('2026-06-04T10:31:00.000Z'); + }); + + it('rejects resolved:false here (that path uses approvalAlreadyResolvedDataSchema)', () => { + expect(() => + approvalResolveResultSchema.parse({ resolved: false, resolved_at: '2026-06-04T10:31:00Z' }), + ).toThrow(); + }); +}); + +describe('approvalAlreadyResolvedDataSchema (REST §3.6 idempotent 40902)', () => { + it('accepts the idempotent shape', () => { + expect(approvalAlreadyResolvedDataSchema.parse({ resolved: false })).toEqual({ + resolved: false, + }); + }); + + it('rejects resolved:true here', () => { + expect(() => approvalAlreadyResolvedDataSchema.parse({ resolved: true })).toThrow(); + }); +}); + +describe('listPendingApprovalsResponseSchema (REST pending recovery)', () => { + const pendingApproval = { + approval_id: '01J0000000APPROVAL', + session_id: 'sess_x', + tool_call_id: 'tc_1', + tool_name: 'shell.run', + action: 'Run `ls`', + tool_input_display: { kind: 'command', command: 'ls', summary: 'ls' }, + created_at: '2026-06-04T10:30:00Z', + expires_at: '2026-06-04T10:31:00Z', + }; + + it('accepts status=pending query', () => { + expect(listPendingApprovalsQuerySchema.parse({ status: 'pending' })).toEqual({ + status: 'pending', + }); + }); + + it('rejects unsupported status query', () => { + expect(() => + listPendingApprovalsQuerySchema.parse({ status: 'resolved' }), + ).toThrow(); + }); + + it('returns approval request items', () => { + const parsed = listPendingApprovalsResponseSchema.parse({ + items: [pendingApproval], + }); + expect(parsed.items[0]?.approval_id).toBe('01J0000000APPROVAL'); + expect(parsed.items[0]?.tool_input_display).toEqual({ + kind: 'command', + command: 'ls', + summary: 'ls', + }); + }); +}); diff --git a/packages/protocol/src/__tests__/envelope.test.ts b/packages/protocol/src/__tests__/envelope.test.ts new file mode 100644 index 000000000..ba8282097 --- /dev/null +++ b/packages/protocol/src/__tests__/envelope.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { envelopeSchema, errEnvelope, okEnvelope, type Envelope } from '../envelope'; +import { ErrorCode, ErrorCodeReason } from '../error-codes'; + +describe('envelope', () => { + it('okEnvelope round-trips through envelopeSchema', () => { + const built = okEnvelope({ ok: true }, 'req_test'); + + expect(built).toEqual({ + code: 0, + msg: 'success', + data: { ok: true }, + request_id: 'req_test', + }); + + const schema = envelopeSchema(z.object({ ok: z.boolean() })); + const parsed = schema.parse(built); + expect(parsed).toEqual(built); + }); + + it('errEnvelope round-trips with data: null', () => { + const built = errEnvelope(ErrorCode.SESSION_NOT_FOUND, 'session abc123 does not exist', 'req_x'); + + expect(built).toEqual({ + code: 40401, + msg: 'session abc123 does not exist', + data: null, + request_id: 'req_x', + }); + + const parsed = envelopeSchema(z.any()).parse(built); + expect(parsed.data).toBeNull(); + expect(parsed.code).toBe(40401); + }); + + it('envelopeSchema rejects non-integer code', () => { + const schema = envelopeSchema(z.unknown()); + expect(schema.safeParse({ code: 1.5, msg: 'x', data: null, request_id: 'r' }).success).toBe( + false, + ); + }); + + it('envelopeSchema rejects missing request_id', () => { + const schema = envelopeSchema(z.unknown()); + expect(schema.safeParse({ code: 0, msg: 'success', data: null }).success).toBe(false); + }); + + it('wire shape matches the daemon helper byte-for-byte', () => { + const ours = okEnvelope({ id: 'sess_1' }, 'req_y'); + const oursJson = JSON.stringify(ours); + expect(oursJson).toBe('{"code":0,"msg":"success","data":{"id":"sess_1"},"request_id":"req_y"}'); + + const errJson = JSON.stringify(errEnvelope(40001, 'validation failed', 'req_z')); + expect(errJson).toBe( + '{"code":40001,"msg":"validation failed","data":null,"request_id":"req_z"}', + ); + }); +}); + +describe('error-codes', () => { + it('canonical codes match REST.md §1.4', () => { + expect(ErrorCode.SUCCESS).toBe(0); + expect(ErrorCode.VALIDATION_FAILED).toBe(40001); + expect(ErrorCode.SESSION_NOT_FOUND).toBe(40401); + expect(ErrorCode.APPROVAL_EXPIRED).toBe(41001); + expect(ErrorCode.FS_WATCH_LIMIT_EXCEEDED).toBe(42902); + expect(ErrorCode.INTERNAL_ERROR).toBe(50001); + expect(ErrorCode.TOOL_EXECUTION_FAILED).toBe(60001); + }); + + it('ErrorCodeReason maps every numeric code to its domain.reason label', () => { + expect(ErrorCodeReason[ErrorCode.SESSION_NOT_FOUND]).toBe('session.not_found'); + expect(ErrorCodeReason[ErrorCode.PROVIDER_NOT_FOUND]).toBe('provider.not_found'); + expect(ErrorCodeReason[ErrorCode.MODEL_NOT_FOUND]).toBe('model.not_found'); + expect(ErrorCodeReason[ErrorCode.VALIDATION_FAILED]).toBe('validation.failed'); + expect(ErrorCodeReason[ErrorCode.FS_WATCH_LIMIT_EXCEEDED]).toBe('fs.watch_limit_exceeded'); + }); + + it('reserved codes are not redefined (40101, 50002 absent)', () => { + const allValues = Object.values(ErrorCode); + expect(allValues).not.toContain(40101); + expect(allValues).not.toContain(40102); + expect(allValues).not.toContain(40103); + expect(allValues).not.toContain(42901); + expect(allValues).not.toContain(50002); + }); + + it('ErrorCode type narrows to the literal union', () => { + const code: ErrorCode = ErrorCode.SESSION_NOT_FOUND; + const env: Envelope = errEnvelope(code, 'x', 'req_t'); + expect(env.code).toBe(40401); + }); +}); diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts new file mode 100644 index 000000000..79c70841d --- /dev/null +++ b/packages/protocol/src/__tests__/events.test.ts @@ -0,0 +1,52 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import type { Event } from '../events'; +import type { ToolInputDisplay } from '../display'; + +type _AssertEventNonNever = Event extends never ? never : true; +const _assertEvent: _AssertEventNonNever = true; + +type _AssertToolInputDisplayNonNever = ToolInputDisplay extends never ? never : true; +const _assertDisplay: _AssertToolInputDisplayNonNever = true; + +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); +const sdkPackageName = ['@moonshot-ai', 'kimi-code-sdk'].join('/'); + +function readPackageFiles(): string { + const files = ['package.json', ...sourceFiles(join(packageRoot, 'src'))]; + return files + .map((file) => readFileSync(join(packageRoot, file), 'utf8')) + .join('\n'); +} + +function sourceFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + files.push(...sourceFiles(full)); + } else if (entry.endsWith('.ts')) { + files.push(relative(packageRoot, full)); + } + } + return files; +} + +describe('events / display re-exports', () => { + it('does not depend on the node SDK package', () => { + expect(readPackageFiles()).not.toContain(sdkPackageName); + }); + + it('Event re-export is non-never (compile-time check passed)', () => { + expect(_assertEvent).toBe(true); + }); + + it('ToolInputDisplay re-export is non-never (12-arm union preserved)', () => { + expect(_assertDisplay).toBe(true); + }); +}); diff --git a/packages/protocol/src/__tests__/file.test.ts b/packages/protocol/src/__tests__/file.test.ts new file mode 100644 index 000000000..3985c8ca5 --- /dev/null +++ b/packages/protocol/src/__tests__/file.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { fileMetaSchema, type FileMeta } from '../file'; + +describe('fileMetaSchema (W12.2 / Chain 15)', () => { + const base: FileMeta = { + id: '01JABCDEFGHJKMNPQRSTVWXYZ0', + name: 'screenshot.png', + media_type: 'image/png', + size: 4096, + created_at: '2026-06-04T10:00:00.000Z', + }; + + it('round-trips a minimal record without expires_at', () => { + expect(fileMetaSchema.parse(base)).toEqual(base); + expect(fileMetaSchema.parse(base).expires_at).toBeUndefined(); + }); + + it('round-trips a record with expires_at', () => { + const withExpiry: FileMeta = { + ...base, + expires_at: '2026-06-05T10:00:00.000Z', + }; + const parsed = fileMetaSchema.parse(withExpiry); + expect(parsed.expires_at).toBe('2026-06-05T10:00:00.000Z'); + }); + + it('accepts size=0 (empty file is legal)', () => { + expect(fileMetaSchema.parse({ ...base, size: 0 }).size).toBe(0); + }); + + it('rejects a negative size', () => { + expect( + fileMetaSchema.safeParse({ ...base, size: -1 }).success, + ).toBe(false); + }); + + it('rejects empty id / name / media_type', () => { + expect(fileMetaSchema.safeParse({ ...base, id: '' }).success).toBe(false); + expect(fileMetaSchema.safeParse({ ...base, name: '' }).success).toBe(false); + expect( + fileMetaSchema.safeParse({ ...base, media_type: '' }).success, + ).toBe(false); + }); + + it('rejects a non-ISO created_at', () => { + expect( + fileMetaSchema.safeParse({ ...base, created_at: 'not a date' }).success, + ).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/fs.test.ts b/packages/protocol/src/__tests__/fs.test.ts new file mode 100644 index 000000000..738a637eb --- /dev/null +++ b/packages/protocol/src/__tests__/fs.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import { + fsChangeActionSchema, + fsChangeEntrySchema, + fsChangeEventSchema, + fsChangeKindSchema, + fsEntrySchema, + fsGitStatusEntrySchema, + fsGitStatusSchema, + fsGrepFileHitSchema, + fsGrepMatchSchema, + fsKindSchema, + fsSearchHitSchema, + type FsChangeEntry, + type FsChangeEvent, + type FsEntry, + type FsGitStatusEntry, + type FsGrepFileHit, + type FsGrepMatch, + type FsSearchHit, +} from '../fs'; + +describe('fsKindSchema', () => { + it.each(['file', 'directory', 'symlink'] as const)('accepts %s', (k) => { + expect(fsKindSchema.parse(k)).toBe(k); + }); + + it("rejects agent-core-ish 'dir' / 'other' literals", () => { + expect(fsKindSchema.safeParse('dir').success).toBe(false); + expect(fsKindSchema.safeParse('other').success).toBe(false); + }); +}); + +describe('fsGitStatusSchema', () => { + it.each([ + 'clean', + 'modified', + 'added', + 'deleted', + 'renamed', + 'untracked', + 'ignored', + 'conflicted', + ] as const)('accepts %s', (s) => { + expect(fsGitStatusSchema.parse(s)).toBe(s); + }); + + it('rejects unknown git status', () => { + expect(fsGitStatusSchema.safeParse('staged').success).toBe(false); + }); +}); + +describe('fsEntrySchema', () => { + const minimal: FsEntry = { + path: 'src/index.ts', + name: 'index.ts', + kind: 'file', + modified_at: '2026-06-04T10:00:00.000Z', + }; + + it('round-trips a minimal file entry (no optional fields)', () => { + expect(fsEntrySchema.parse(minimal)).toEqual(minimal); + }); + + it('round-trips a fully populated entry', () => { + const full: FsEntry = { + ...minimal, + size: 1234, + etag: 'abcdef', + mime: 'text/typescript', + language_id: 'typescript', + is_binary: false, + git_status: 'modified', + }; + expect(fsEntrySchema.parse(full)).toEqual(full); + }); + + it('round-trips a directory with child_count', () => { + const dir: FsEntry = { + path: 'src', + name: 'src', + kind: 'directory', + modified_at: '2026-06-04T10:00:00.000Z', + child_count: 42, + }; + expect(fsEntrySchema.parse(dir).child_count).toBe(42); + }); + + it('round-trips a symlink with is_symlink_to', () => { + const sym: FsEntry = { + path: 'link', + name: 'link', + kind: 'symlink', + modified_at: '2026-06-04T10:00:00.000Z', + is_symlink_to: 'target', + }; + expect(fsEntrySchema.parse(sym).is_symlink_to).toBe('target'); + }); + + it('rejects negative size', () => { + expect(fsEntrySchema.safeParse({ ...minimal, size: -1 }).success).toBe(false); + }); + + it('rejects malformed modified_at (no timezone)', () => { + const bad = { ...minimal, modified_at: '2026-06-04T10:00:00' }; + expect(fsEntrySchema.safeParse(bad).success).toBe(false); + }); + + it('rejects negative child_count', () => { + const bad: unknown = { + path: 'src', + name: 'src', + kind: 'directory', + modified_at: '2026-06-04T10:00:00.000Z', + child_count: -1, + }; + expect(fsEntrySchema.safeParse(bad).success).toBe(false); + }); +}); + +describe('fsSearchHitSchema (W11.1 / Chain 11)', () => { + const hit: FsSearchHit = { + path: 'src/components/Button.tsx', + name: 'Button.tsx', + kind: 'file', + score: 0.87, + match_positions: [16, 17, 18, 19], + }; + + it('round-trips a populated hit', () => { + expect(fsSearchHitSchema.parse(hit)).toEqual(hit); + }); + + it('rejects score outside 0..1', () => { + expect(fsSearchHitSchema.safeParse({ ...hit, score: 1.5 }).success).toBe(false); + expect(fsSearchHitSchema.safeParse({ ...hit, score: -0.1 }).success).toBe(false); + }); + + it('rejects negative match positions', () => { + expect( + fsSearchHitSchema.safeParse({ ...hit, match_positions: [-1] }).success, + ).toBe(false); + }); + + it('accepts an empty match_positions list', () => { + expect(fsSearchHitSchema.parse({ ...hit, match_positions: [] }).match_positions).toEqual([]); + }); +}); + +describe('fsGrepMatchSchema (W11.1 / Chain 11)', () => { + const match: FsGrepMatch = { + line: 42, + col: 7, + text: ' console.log(message);', + before: ['function greet() {', ' const message = "hello";'], + after: ['}', ''], + }; + + it('round-trips a populated match', () => { + expect(fsGrepMatchSchema.parse(match)).toEqual(match); + }); + + it('rejects zero line / col (1-based)', () => { + expect(fsGrepMatchSchema.safeParse({ ...match, line: 0 }).success).toBe(false); + expect(fsGrepMatchSchema.safeParse({ ...match, col: 0 }).success).toBe(false); + }); + + it('accepts empty before / after arrays', () => { + const m: FsGrepMatch = { ...match, before: [], after: [] }; + expect(fsGrepMatchSchema.parse(m).before).toEqual([]); + }); +}); + +describe('fsGrepFileHitSchema (W11.1 / Chain 11)', () => { + it('round-trips a file with one match', () => { + const fh: FsGrepFileHit = { + path: 'src/index.ts', + matches: [ + { + line: 1, + col: 1, + text: 'export {}', + before: [], + after: [], + }, + ], + }; + expect(fsGrepFileHitSchema.parse(fh)).toEqual(fh); + }); + + it('round-trips a file with multiple matches', () => { + const fh: FsGrepFileHit = { + path: 'README.md', + matches: [ + { line: 1, col: 1, text: '# Project', before: [], after: [] }, + { line: 5, col: 3, text: 'Project description', before: [], after: [] }, + ], + }; + expect(fsGrepFileHitSchema.parse(fh).matches).toHaveLength(2); + }); +}); + +describe('fsGitStatusEntrySchema (W11.2 / Chain 12)', () => { + const entry: FsGitStatusEntry = { + path: 'src/index.ts', + status: 'modified', + }; + + it('round-trips a minimal entry', () => { + expect(fsGitStatusEntrySchema.parse(entry)).toEqual(entry); + }); + + it('round-trips a renamed entry with rename_from', () => { + const ren: FsGitStatusEntry = { + path: 'src/new-name.ts', + status: 'renamed', + rename_from: 'src/old-name.ts', + }; + expect(fsGitStatusEntrySchema.parse(ren).rename_from).toBe('src/old-name.ts'); + }); + + it('rejects an unknown status', () => { + expect( + fsGitStatusEntrySchema.safeParse({ ...entry, status: 'staged' }).success, + ).toBe(false); + }); +}); + +describe('fsChangeKindSchema (W12 / Chain 14)', () => { + it.each(['file', 'directory', 'symlink'] as const)('accepts %s', (k) => { + expect(fsChangeKindSchema.parse(k)).toBe(k); + }); + + it('rejects unknown kinds (chokidar leakage like "addDir")', () => { + expect(fsChangeKindSchema.safeParse('addDir').success).toBe(false); + expect(fsChangeKindSchema.safeParse('dir').success).toBe(false); + }); +}); + +describe('fsChangeActionSchema (W12 / Chain 14)', () => { + it.each(['created', 'modified', 'deleted'] as const)( + 'accepts %s', + (a) => { + expect(fsChangeActionSchema.parse(a)).toBe(a); + }, + ); + + it('rejects chokidar raw event names (must collapse before wire)', () => { + for (const raw of ['add', 'change', 'unlink', 'addDir', 'unlinkDir']) { + expect(fsChangeActionSchema.safeParse(raw).success).toBe(false); + } + }); +}); + +describe('fsChangeEntrySchema (W12 / Chain 14)', () => { + it('round-trips a minimal created-file entry', () => { + const entry: FsChangeEntry = { + path: 'src/index.ts', + change: 'created', + kind: 'file', + }; + expect(fsChangeEntrySchema.parse(entry)).toEqual(entry); + }); + + it('accepts size_delta + etag on a modified file', () => { + const entry: FsChangeEntry = { + path: 'src/foo.ts', + change: 'modified', + kind: 'file', + size_delta: 17, + etag: 'abc123', + }; + const parsed = fsChangeEntrySchema.parse(entry); + expect(parsed.size_delta).toBe(17); + expect(parsed.etag).toBe('abc123'); + }); + + it('accepts a negative size_delta (file shrank)', () => { + expect( + fsChangeEntrySchema.parse({ + path: 'src/big.log', + change: 'modified', + kind: 'file', + size_delta: -1024, + }).size_delta, + ).toBe(-1024); + }); + + it('round-trips a deleted-directory entry', () => { + const entry: FsChangeEntry = { + path: 'old/', + change: 'deleted', + kind: 'directory', + }; + expect(fsChangeEntrySchema.parse(entry)).toEqual(entry); + }); +}); + +describe('fsChangeEventSchema (W12 / Chain 14)', () => { + it('round-trips a non-truncated event with two changes', () => { + const ev: FsChangeEvent = { + changes: [ + { path: 'a.txt', change: 'created', kind: 'file' }, + { path: 'b.txt', change: 'modified', kind: 'file', size_delta: 5 }, + ], + coalesced_window_ms: 200, + }; + const parsed = fsChangeEventSchema.parse(ev); + expect(parsed.changes.length).toBe(2); + expect(parsed.truncated).toBeUndefined(); + }); + + it('round-trips a truncated burst notification', () => { + const ev: FsChangeEvent = { + changes: [], + coalesced_window_ms: 200, + truncated: true, + count: 1742, + }; + const parsed = fsChangeEventSchema.parse(ev); + expect(parsed.truncated).toBe(true); + expect(parsed.count).toBe(1742); + expect(parsed.changes).toEqual([]); + }); + + it('rejects a missing coalesced_window_ms (always echoed)', () => { + expect( + fsChangeEventSchema.safeParse({ changes: [] }).success, + ).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/message.test.ts b/packages/protocol/src/__tests__/message.test.ts new file mode 100644 index 000000000..9e2c61d05 --- /dev/null +++ b/packages/protocol/src/__tests__/message.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { + fileContentSchema, + imageContentSchema, + messageContentSchema, + messageRoleSchema, + messageSchema, + textContentSchema, + thinkingContentSchema, + toolResultContentSchema, + toolUseContentSchema, +} from '../message'; + +describe('messageRoleSchema', () => { + it.each(['user', 'assistant', 'tool', 'system'] as const)('accepts %s', (role) => { + expect(messageRoleSchema.parse(role)).toBe(role); + }); + + it('rejects an unknown role', () => { + expect(messageRoleSchema.safeParse('cat').success).toBe(false); + }); +}); + +describe('messageContentSchema variants', () => { + it('parses text content', () => { + const parsed = textContentSchema.parse({ type: 'text', text: 'hello' }); + expect(parsed.text).toBe('hello'); + }); + + it('parses tool_use content', () => { + const parsed = toolUseContentSchema.parse({ + type: 'tool_use', + tool_call_id: 'call_1', + tool_name: 'Bash', + input: { command: 'ls' }, + }); + expect(parsed.tool_name).toBe('Bash'); + }); + + it('parses tool_result content with is_error', () => { + const parsed = toolResultContentSchema.parse({ + type: 'tool_result', + tool_call_id: 'call_1', + output: 'error', + is_error: true, + }); + expect(parsed.is_error).toBe(true); + }); + + it('parses image url source', () => { + const parsed = imageContentSchema.parse({ + type: 'image', + source: { kind: 'url', url: 'https://example.com/a.png' }, + }); + expect(parsed.source.kind).toBe('url'); + }); + + it('parses image base64 source', () => { + const parsed = imageContentSchema.parse({ + type: 'image', + source: { kind: 'base64', media_type: 'image/png', data: 'aGVsbG8=' }, + }); + expect(parsed.source.kind).toBe('base64'); + }); + + it('parses file content', () => { + const parsed = fileContentSchema.parse({ + type: 'file', + file_id: 'file_01', + name: 'doc.pdf', + media_type: 'application/pdf', + size: 12345, + }); + expect(parsed.size).toBe(12345); + }); + + it('parses thinking content', () => { + const parsed = thinkingContentSchema.parse({ + type: 'thinking', + thinking: 'pondering', + }); + expect(parsed.thinking).toBe('pondering'); + }); + + it('messageContentSchema discriminates by type', () => { + const parsed = messageContentSchema.parse({ type: 'text', text: 'hi' }); + expect(parsed.type).toBe('text'); + }); + + it('rejects unknown content type', () => { + expect(messageContentSchema.safeParse({ type: 'audio', text: '' }).success).toBe(false); + }); +}); + +describe('messageSchema', () => { + const validMessage = { + id: 'msg_01HZZZZ', + session_id: 'sess_01', + role: 'assistant' as const, + content: [{ type: 'text' as const, text: 'hi' }], + created_at: '2026-06-04T10:30:00.000Z', + }; + + it('parses a minimal assistant message', () => { + const parsed = messageSchema.parse(validMessage); + expect(parsed.id).toBe('msg_01HZZZZ'); + expect(parsed.role).toBe('assistant'); + expect(parsed.content[0]?.type).toBe('text'); + }); + + it('parses with optional fields', () => { + const parsed = messageSchema.parse({ + ...validMessage, + prompt_id: 'prompt_01', + parent_message_id: 'msg_parent', + metadata: { tag: 'demo' }, + }); + expect(parsed.prompt_id).toBe('prompt_01'); + expect(parsed.parent_message_id).toBe('msg_parent'); + expect(parsed.metadata).toEqual({ tag: 'demo' }); + }); + + it('rejects empty id', () => { + expect(messageSchema.safeParse({ ...validMessage, id: '' }).success).toBe(false); + }); + + it('rejects bad ISO timestamp', () => { + expect( + messageSchema.safeParse({ ...validMessage, created_at: 'yesterday' }).success, + ).toBe(false); + }); + + it('accepts tool message with tool_result content', () => { + const parsed = messageSchema.parse({ + ...validMessage, + role: 'tool', + content: [ + { + type: 'tool_result', + tool_call_id: 'call_1', + output: 'done', + }, + ], + }); + expect(parsed.role).toBe('tool'); + }); +}); diff --git a/packages/protocol/src/__tests__/model-catalog.test.ts b/packages/protocol/src/__tests__/model-catalog.test.ts new file mode 100644 index 000000000..a485b88c2 --- /dev/null +++ b/packages/protocol/src/__tests__/model-catalog.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + getProviderResponseSchema, + listModelsResponseSchema, + listProvidersResponseSchema, + modelCatalogItemSchema, + providerCatalogItemSchema, + providerCatalogStatusSchema, + setDefaultModelResponseSchema, + type ModelCatalogItem, + type ProviderCatalogItem, +} from '../index'; + +describe('model catalog schemas', () => { + const model: ModelCatalogItem = { + provider: 'kimi', + model: 'k2', + display_name: 'Kimi K2', + max_context_size: 131072, + capabilities: ['thinking'], + }; + + const provider: ProviderCatalogItem = { + id: 'kimi', + type: 'kimi', + base_url: 'https://api.example.test/v1', + default_model: 'k2', + has_api_key: true, + status: 'connected', + models: ['k2'], + }; + + it('round-trips a model catalog item', () => { + expect(modelCatalogItemSchema.parse(model)).toEqual(model); + }); + + it('rejects invalid model context sizes', () => { + expect( + modelCatalogItemSchema.safeParse({ ...model, max_context_size: 0 }).success, + ).toBe(false); + }); + + it.each(['connected', 'error', 'unconfigured'] as const)( + 'accepts provider status %s', + (status) => { + expect(providerCatalogStatusSchema.parse(status)).toBe(status); + }, + ); + + it('round-trips a provider catalog item', () => { + expect(providerCatalogItemSchema.parse(provider)).toEqual(provider); + expect(getProviderResponseSchema.parse(provider)).toEqual(provider); + }); + + it('round-trips list responses and set-default response', () => { + expect(listModelsResponseSchema.parse({ items: [model] })).toEqual({ + items: [model], + }); + expect(listProvidersResponseSchema.parse({ items: [provider] })).toEqual({ + items: [provider], + }); + expect( + setDefaultModelResponseSchema.parse({ default_model: 'k2', model }), + ).toEqual({ + default_model: 'k2', + model, + }); + }); +}); diff --git a/packages/protocol/src/__tests__/pagination.test.ts b/packages/protocol/src/__tests__/pagination.test.ts new file mode 100644 index 000000000..9e804163f --- /dev/null +++ b/packages/protocol/src/__tests__/pagination.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { ErrorCode } from '../error-codes'; +import { CursorQuery, cursorQuerySchema, pageResponseSchema } from '../pagination'; +import { z } from 'zod'; + +describe('pagination — CursorQuery', () => { + it('alias and schema are the same object', () => { + expect(CursorQuery).toBe(cursorQuerySchema); + }); + + it('accepts empty query (first-page fetch)', () => { + expect(cursorQuerySchema.safeParse({}).success).toBe(true); + }); + + it('accepts before_id alone', () => { + const result = cursorQuerySchema.safeParse({ before_id: 'msg_01HX', page_size: 50 }); + expect(result.success).toBe(true); + }); + + it('accepts after_id alone', () => { + const result = cursorQuerySchema.safeParse({ after_id: 'msg_01HX', page_size: 50 }); + expect(result.success).toBe(true); + }); + + it('rejects before_id + after_id simultaneously with 40001-mapped issue', () => { + const result = cursorQuerySchema.safeParse({ before_id: 'x', after_id: 'y' }); + expect(result.success).toBe(false); + if (!result.success) { + const mutexIssue = result.error.issues.find( + (issue) => + issue.code === 'custom' && + (issue.params as { code?: number } | undefined)?.code === ErrorCode.VALIDATION_FAILED, + ); + expect(mutexIssue).toBeDefined(); + expect(mutexIssue?.message).toMatch(/mutually exclusive/); + } + }); + + it('rejects page_size below 1', () => { + expect(cursorQuerySchema.safeParse({ page_size: 0 }).success).toBe(false); + }); + + it('rejects page_size above 100 (SCHEMAS.md §1.3 hard upper bound)', () => { + expect(cursorQuerySchema.safeParse({ page_size: 101 }).success).toBe(false); + expect(cursorQuerySchema.safeParse({ page_size: 300 }).success).toBe(false); + }); + + it('rejects non-integer page_size', () => { + expect(cursorQuerySchema.safeParse({ page_size: 50.5 }).success).toBe(false); + }); +}); + +describe('pagination — pageResponseSchema', () => { + it('shapes `data` as { items, has_more } only — no next_cursor', () => { + const schema = pageResponseSchema(z.object({ id: z.string() })); + const value = schema.parse({ + items: [{ id: 'a' }, { id: 'b' }], + has_more: true, + }); + expect(value.items).toHaveLength(2); + expect(value.has_more).toBe(true); + expect((value as Record)['next_cursor']).toBeUndefined(); + }); + + it('rejects missing has_more', () => { + const schema = pageResponseSchema(z.unknown()); + expect(schema.safeParse({ items: [] }).success).toBe(false); + }); + + it('rejects non-array items', () => { + const schema = pageResponseSchema(z.unknown()); + expect(schema.safeParse({ items: 'oops', has_more: false }).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/question.test.ts b/packages/protocol/src/__tests__/question.test.ts new file mode 100644 index 000000000..e4353e99a --- /dev/null +++ b/packages/protocol/src/__tests__/question.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect } from 'vitest'; + +import { + questionAnswerMethodSchema, + questionAnswerSchema, + questionItemSchema, + questionOptionSchema, + questionRequestSchema, + questionResponseSchema, +} from '../question'; +import { + questionResolveRequestSchema, + questionResolveResultSchema, + questionAlreadyResolvedDataSchema, + questionDismissResultSchema, + listPendingQuestionsQuerySchema, + listPendingQuestionsResponseSchema, +} from '../rest/question'; + +describe('questionOptionSchema (SCHEMAS §6.2)', () => { + it('accepts id+label', () => { + expect(questionOptionSchema.parse({ id: 'opt_1', label: 'Yes' })).toEqual({ + id: 'opt_1', + label: 'Yes', + }); + }); + + it('accepts optional description', () => { + const parsed = questionOptionSchema.parse({ + id: 'opt_1', + label: 'Yes', + description: 'long form', + }); + expect(parsed.description).toBe('long form'); + }); + + it('rejects missing id', () => { + expect(() => questionOptionSchema.parse({ label: 'Yes' })).toThrow(); + }); +}); + +describe('questionItemSchema (SCHEMAS §6.2)', () => { + const baseItem = { + id: 'q_1', + question: 'Which?', + options: [ + { id: 'opt_1', label: 'Yes' }, + { id: 'opt_2', label: 'No' }, + ], + }; + + it('accepts a minimum 2-option single-select item', () => { + expect(questionItemSchema.parse(baseItem).id).toBe('q_1'); + }); + + it('accepts a 4-option multi_select item with allow_other', () => { + const parsed = questionItemSchema.parse({ + ...baseItem, + options: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + { id: 'd', label: 'D' }, + ], + multi_select: true, + allow_other: true, + other_label: 'Other', + }); + expect(parsed.multi_select).toBe(true); + expect(parsed.allow_other).toBe(true); + expect(parsed.other_label).toBe('Other'); + }); + + it('rejects fewer than 2 options', () => { + expect(() => questionItemSchema.parse({ ...baseItem, options: [baseItem.options[0]] })).toThrow(); + }); + + it('rejects more than 4 options', () => { + const tooMany = Array.from({ length: 5 }, (_, i) => ({ id: `o${i}`, label: `L${i}` })); + expect(() => questionItemSchema.parse({ ...baseItem, options: tooMany })).toThrow(); + }); +}); + +describe('questionRequestSchema (SCHEMAS §6.2)', () => { + const baseReq = { + question_id: '01J_QUESTION', + session_id: 'sess_x', + questions: [ + { + id: 'q_1', + question: 'Which?', + options: [ + { id: 'opt_1', label: 'A' }, + { id: 'opt_2', label: 'B' }, + ], + }, + ], + created_at: '2026-06-04T10:30:00Z', + expires_at: '2026-06-04T10:31:00Z', + }; + + it('accepts a 1-question request', () => { + expect(questionRequestSchema.parse(baseReq).question_id).toBe('01J_QUESTION'); + }); + + it('rejects 0 questions', () => { + expect(() => questionRequestSchema.parse({ ...baseReq, questions: [] })).toThrow(); + }); + + it('rejects more than 4 questions', () => { + const tooMany = Array.from({ length: 5 }, (_, i) => ({ + id: `q${i}`, + question: `?${i}`, + options: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ], + })); + expect(() => questionRequestSchema.parse({ ...baseReq, questions: tooMany })).toThrow(); + }); + + it('normalizes timestamps to UTC', () => { + const parsed = questionRequestSchema.parse({ + ...baseReq, + created_at: '2026-06-04T18:30:00+08:00', + }); + expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z'); + }); +}); + +describe('questionAnswerSchema 5-kind discriminated union (SCHEMAS §6.2)', () => { + it.each([ + ['single', { kind: 'single', option_id: 'opt_1' }], + ['multi', { kind: 'multi', option_ids: ['opt_1', 'opt_2'] }], + ['other', { kind: 'other', text: 'free form' }], + [ + 'multi_with_other', + { kind: 'multi_with_other', option_ids: ['opt_1'], other_text: 'tail' }, + ], + ['skipped', { kind: 'skipped' }], + ] as const)('accepts %s kind', (_, val) => { + expect(questionAnswerSchema.parse(val)).toEqual(val); + }); + + it('rejects single with empty option_id', () => { + expect(() => questionAnswerSchema.parse({ kind: 'single', option_id: '' })).toThrow(); + }); + + it('rejects multi with empty option_ids array', () => { + expect(() => questionAnswerSchema.parse({ kind: 'multi', option_ids: [] })).toThrow(); + }); + + it('rejects unknown kind', () => { + expect(() => questionAnswerSchema.parse({ kind: 'rangefinder', value: 42 })).toThrow(); + }); +}); + +describe('questionAnswerMethodSchema', () => { + it.each(['enter', 'space', 'number_key', 'click'] as const)('accepts %s', (m) => { + expect(questionAnswerMethodSchema.parse(m)).toBe(m); + }); + + it('rejects unknown method', () => { + expect(() => questionAnswerMethodSchema.parse('voice')).toThrow(); + }); +}); + +describe('questionResponseSchema (SCHEMAS §6.2)', () => { + it('accepts a single-answer response with all optional fields', () => { + const parsed = questionResponseSchema.parse({ + answers: { q_1: { kind: 'single', option_id: 'opt_1' } }, + method: 'click', + note: 'all done', + }); + expect(parsed.answers['q_1']).toEqual({ kind: 'single', option_id: 'opt_1' }); + expect(parsed.method).toBe('click'); + expect(parsed.note).toBe('all done'); + }); + + it('accepts a mixed-kind response (partial-answer pattern)', () => { + const parsed = questionResponseSchema.parse({ + answers: { + q_1: { kind: 'single', option_id: 'opt_1' }, + q_2: { kind: 'multi', option_ids: ['opt_1', 'opt_2'] }, + q_3: { kind: 'other', text: 'free' }, + q_4: { kind: 'skipped' }, + }, + }); + expect(Object.keys(parsed.answers)).toHaveLength(4); + }); +}); + +describe('questionResolveRequestSchema (REST §3.6)', () => { + it('aliases questionResponseSchema', () => { + const parsed = questionResolveRequestSchema.parse({ + answers: { q_1: { kind: 'skipped' } }, + }); + expect(parsed.answers['q_1']).toEqual({ kind: 'skipped' }); + }); +}); + +describe('questionResolveResultSchema (REST §3.6)', () => { + it('requires resolved:true literal + ISO resolved_at', () => { + const parsed = questionResolveResultSchema.parse({ + resolved: true, + resolved_at: '2026-06-04T10:31:00Z', + }); + expect(parsed.resolved).toBe(true); + }); + + it('rejects resolved:false', () => { + expect(() => + questionResolveResultSchema.parse({ resolved: false, resolved_at: '2026-06-04T10:31:00Z' }), + ).toThrow(); + }); +}); + +describe('questionAlreadyResolvedDataSchema (REST §3.6 idempotent 40902)', () => { + it('accepts resolved:false', () => { + expect(questionAlreadyResolvedDataSchema.parse({ resolved: false })).toEqual({ + resolved: false, + }); + }); + + it('rejects resolved:true', () => { + expect(() => questionAlreadyResolvedDataSchema.parse({ resolved: true })).toThrow(); + }); +}); + +describe('questionDismissResultSchema (REST §3.6 dismiss with code 40909)', () => { + it('requires dismissed:true literal + ISO dismissed_at', () => { + const parsed = questionDismissResultSchema.parse({ + dismissed: true, + dismissed_at: '2026-06-04T10:32:00Z', + }); + expect(parsed.dismissed).toBe(true); + expect(parsed.dismissed_at).toBe('2026-06-04T10:32:00.000Z'); + }); + + it('rejects dismissed:false', () => { + expect(() => + questionDismissResultSchema.parse({ + dismissed: false, + dismissed_at: '2026-06-04T10:32:00Z', + }), + ).toThrow(); + }); +}); + +describe('listPendingQuestionsResponseSchema (REST pending recovery)', () => { + const pendingQuestion = { + question_id: '01J_QUESTION', + session_id: 'sess_x', + questions: [ + { + id: 'q_1', + question: 'Which?', + options: [ + { id: 'opt_1', label: 'A' }, + { id: 'opt_2', label: 'B' }, + ], + }, + ], + created_at: '2026-06-04T10:30:00Z', + expires_at: '2026-06-04T10:31:00Z', + }; + + it('accepts status=pending query', () => { + expect(listPendingQuestionsQuerySchema.parse({ status: 'pending' })).toEqual({ + status: 'pending', + }); + }); + + it('rejects unsupported status query', () => { + expect(() => + listPendingQuestionsQuerySchema.parse({ status: 'answered' }), + ).toThrow(); + }); + + it('returns question request items', () => { + const parsed = listPendingQuestionsResponseSchema.parse({ + items: [pendingQuestion], + }); + expect(parsed.items[0]?.question_id).toBe('01J_QUESTION'); + expect(parsed.items[0]?.questions[0]?.options).toHaveLength(2); + }); +}); diff --git a/packages/protocol/src/__tests__/request-id.test.ts b/packages/protocol/src/__tests__/request-id.test.ts new file mode 100644 index 000000000..6fc645350 --- /dev/null +++ b/packages/protocol/src/__tests__/request-id.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { ulid } from 'ulid'; + +import { isUlid, parseOrGenerateRequestId, ulidRegex } from '../request-id'; + +describe('request-id — ulidRegex', () => { + it('matches valid 26-char Crockford ULIDs', () => { + const id = ulid(); + expect(ulidRegex.test(id)).toBe(true); + }); + + it('rejects malformed strings', () => { + expect(ulidRegex.test('not-a-ulid')).toBe(false); + expect(ulidRegex.test('')).toBe(false); + expect(ulidRegex.test('01HX')).toBe(false); + expect(ulidRegex.test('01ARZ3NDEKTSV4RRFFQ69G5FALI')).toBe(false); + }); +}); + +describe('request-id — parseOrGenerateRequestId', () => { + it('mints a new ULID when input is undefined', () => { + const out = parseOrGenerateRequestId(undefined); + expect(ulidRegex.test(out)).toBe(true); + expect(isUlid(out)).toBe(true); + }); + + it('mints a new ULID when input is malformed (does not echo back)', () => { + const malformed = 'not-a-ulid'; + const out = parseOrGenerateRequestId(malformed); + expect(out).not.toBe(malformed); + expect(ulidRegex.test(out)).toBe(true); + }); + + it('mints a new ULID when input is empty string', () => { + const out = parseOrGenerateRequestId(''); + expect(out).not.toBe(''); + expect(ulidRegex.test(out)).toBe(true); + }); + + it('returns a valid ULID input verbatim', () => { + const supplied = ulid(); + const out = parseOrGenerateRequestId(supplied); + expect(out).toBe(supplied); + }); + + it('two undefined calls return different ULIDs', () => { + const a = parseOrGenerateRequestId(undefined); + const b = parseOrGenerateRequestId(undefined); + expect(a).not.toBe(b); + }); +}); + +describe('request-id — isUlid', () => { + it('accepts a freshly generated ULID', () => { + expect(isUlid(ulid())).toBe(true); + }); + + it('rejects garbage', () => { + expect(isUlid('garbage')).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-auth.test.ts b/packages/protocol/src/__tests__/rest-auth.test.ts new file mode 100644 index 000000000..b826c71a0 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-auth.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + authSummarySchema, + managedProviderStatusSchema, + type AuthSummary, +} from '../rest/auth'; + +describe('authSummarySchema', () => { + const emptyState: AuthSummary = { + ready: false, + providers_count: 0, + default_model: null, + managed_provider: null, + }; + + const readyState: AuthSummary = { + ready: true, + providers_count: 1, + default_model: 'kimi-k2', + managed_provider: { + name: 'kimi-code-oauth', + status: 'authenticated', + }, + }; + + it('round-trips an empty (unprovisioned) state', () => { + const parsed = authSummarySchema.parse(emptyState); + expect(parsed.ready).toBe(false); + expect(parsed.providers_count).toBe(0); + expect(parsed.default_model).toBeNull(); + expect(parsed.managed_provider).toBeNull(); + }); + + it('round-trips a ready state with managed provider', () => { + const parsed = authSummarySchema.parse(readyState); + expect(parsed.ready).toBe(true); + expect(parsed.providers_count).toBe(1); + expect(parsed.default_model).toBe('kimi-k2'); + expect(parsed.managed_provider).toEqual({ + name: 'kimi-code-oauth', + status: 'authenticated', + }); + }); + + it.each(['authenticated', 'expired', 'revoked', 'unauthenticated'] as const)( + 'accepts managed_provider.status = %s', + (status) => { + const parsed = managedProviderStatusSchema.parse(status); + expect(parsed).toBe(status); + }, + ); + + it('rejects an unknown managed_provider.status', () => { + const bad = { + ...readyState, + managed_provider: { name: 'kimi-code-oauth', status: 'pending' }, + }; + expect(authSummarySchema.safeParse(bad).success).toBe(false); + }); + + it('rejects missing ready', () => { + const { ready: _omit, ...rest } = emptyState; + expect(authSummarySchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing providers_count', () => { + const { providers_count: _omit, ...rest } = emptyState; + expect(authSummarySchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing default_model', () => { + const { default_model: _omit, ...rest } = emptyState; + expect(authSummarySchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing managed_provider', () => { + const { managed_provider: _omit, ...rest } = emptyState; + expect(authSummarySchema.safeParse(rest).success).toBe(false); + }); + + it('rejects negative providers_count', () => { + const bad = { ...emptyState, providers_count: -1 }; + expect(authSummarySchema.safeParse(bad).success).toBe(false); + }); + + it('rejects empty managed_provider.name', () => { + const bad = { + ...readyState, + managed_provider: { name: '', status: 'authenticated' as const }, + }; + expect(authSummarySchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-file.test.ts b/packages/protocol/src/__tests__/rest-file.test.ts new file mode 100644 index 000000000..ee08c1a70 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-file.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + deleteFileParamSchema, + deleteFileResponseSchema, + getFileParamSchema, + uploadFileResponseSchema, + type DeleteFileResponse, + type UploadFileResponse, +} from '../rest/file'; + +describe('uploadFileResponseSchema (POST /api/v1/files)', () => { + it('round-trips a FileMeta payload', () => { + const payload: UploadFileResponse = { + id: '01JABCDEFGHJKMNPQRSTVWXYZ0', + name: 'note.txt', + media_type: 'text/plain', + size: 12, + created_at: '2026-06-04T10:00:00.000Z', + }; + expect(uploadFileResponseSchema.parse(payload)).toEqual(payload); + }); +}); + +describe('getFileParamSchema (GET /api/v1/files/{file_id})', () => { + it('accepts a non-empty file_id', () => { + expect(getFileParamSchema.parse({ file_id: 'f_abc' }).file_id).toBe('f_abc'); + }); + + it('rejects an empty file_id', () => { + expect(getFileParamSchema.safeParse({ file_id: '' }).success).toBe(false); + }); +}); + +describe('deleteFileParamSchema + deleteFileResponseSchema (DELETE /api/v1/files/{file_id})', () => { + it('accepts a non-empty file_id', () => { + expect(deleteFileParamSchema.parse({ file_id: 'f_abc' }).file_id).toBe( + 'f_abc', + ); + }); + + it('rejects an empty file_id', () => { + expect(deleteFileParamSchema.safeParse({ file_id: '' }).success).toBe(false); + }); + + it('response shape is exactly `{deleted: true}`', () => { + const ok: DeleteFileResponse = { deleted: true }; + expect(deleteFileResponseSchema.parse(ok)).toEqual({ deleted: true }); + }); + + it('rejects `{deleted: false}` (false-positive defence)', () => { + expect( + deleteFileResponseSchema.safeParse({ deleted: false }).success, + ).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-fs-browse.test.ts b/packages/protocol/src/__tests__/rest-fs-browse.test.ts new file mode 100644 index 000000000..33e71a857 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-fs-browse.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + fsBrowseEntrySchema, + fsBrowseQuerySchema, + fsBrowseResponseSchema, + fsHomeResponseSchema, +} from '../rest/fsBrowse'; + +describe('fsBrowseQuerySchema', () => { + it('accepts an empty query (path defaults to $HOME at the daemon)', () => { + expect(fsBrowseQuerySchema.parse({})).toEqual({}); + }); + + it('accepts a path string', () => { + expect(fsBrowseQuerySchema.parse({ path: '/Users/foo' })).toEqual({ + path: '/Users/foo', + }); + }); + + it('rejects empty string path', () => { + expect(fsBrowseQuerySchema.safeParse({ path: '' }).success).toBe(false); + }); +}); + +describe('fsBrowseEntrySchema', () => { + it('round-trips a non-git dir entry without branch', () => { + const entry = { + name: 'src', + path: '/Users/foo/code/src', + is_dir: true as const, + is_git_repo: false, + }; + expect(fsBrowseEntrySchema.parse(entry)).toEqual(entry); + }); + + it('round-trips a git dir entry with branch', () => { + const entry = { + name: 'kimi-code', + path: '/Users/foo/code/kimi-code', + is_dir: true as const, + is_git_repo: true, + branch: 'main', + }; + expect(fsBrowseEntrySchema.parse(entry)).toEqual(entry); + }); + + it('rejects is_dir=false (only directories are surfaced)', () => { + const bad = { + name: 'README.md', + path: '/Users/foo/code/README.md', + is_dir: false, + is_git_repo: false, + }; + expect(fsBrowseEntrySchema.safeParse(bad).success).toBe(false); + }); +}); + +describe('fsBrowseResponseSchema', () => { + it('accepts a populated response with a non-null parent', () => { + const resp = { + path: '/Users/foo/code', + parent: '/Users/foo', + entries: [ + { + name: 'kimi-code', + path: '/Users/foo/code/kimi-code', + is_dir: true as const, + is_git_repo: true, + branch: 'main', + }, + ], + }; + expect(fsBrowseResponseSchema.parse(resp).entries.length).toBe(1); + }); + + it('accepts parent=null for filesystem roots', () => { + const resp = { + path: '/', + parent: null, + entries: [], + }; + expect(fsBrowseResponseSchema.parse(resp).parent).toBeNull(); + }); +}); + +describe('fsHomeResponseSchema', () => { + it('round-trips an empty recent_roots list', () => { + expect( + fsHomeResponseSchema.parse({ home: '/Users/foo', recent_roots: [] }), + ).toEqual({ home: '/Users/foo', recent_roots: [] }); + }); + + it('round-trips a populated recent_roots list', () => { + const resp = { + home: '/Users/foo', + recent_roots: ['/Users/foo/code/kimi-code', '/Users/foo/code/other'], + }; + expect(fsHomeResponseSchema.parse(resp).recent_roots.length).toBe(2); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-fs.test.ts b/packages/protocol/src/__tests__/rest-fs.test.ts new file mode 100644 index 000000000..7d6ebac66 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-fs.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest'; + +import { + fsDownloadParamsSchema, + fsGitStatusRequestSchema, + fsGitStatusResponseSchema, + fsGrepRequestSchema, + fsGrepResponseSchema, + fsListManyRequestSchema, + fsListManyResponseSchema, + fsListRequestSchema, + fsListResponseSchema, + fsReadRequestSchema, + fsReadResponseSchema, + fsSearchRequestSchema, + fsSearchResponseSchema, + fsStatManyRequestSchema, + fsStatManyResponseSchema, + fsStatRequestSchema, +} from '../rest/fs'; + +describe('fsListRequestSchema', () => { + it('applies all defaults on empty body', () => { + const parsed = fsListRequestSchema.parse({}); + expect(parsed).toEqual({ + path: '.', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: true, + sort: 'type_first', + include_git_status: false, + }); + }); + + it('round-trips a fully populated request', () => { + const body = { + path: 'src', + depth: 3, + limit: 500, + show_hidden: true, + follow_gitignore: false, + exclude_globs: ['**/node_modules/**'], + sort: 'mtime_desc' as const, + include_git_status: true, + }; + expect(fsListRequestSchema.parse(body)).toEqual(body); + }); + + it('rejects depth > 10', () => { + expect(fsListRequestSchema.safeParse({ depth: 11 }).success).toBe(false); + }); + + it('rejects limit > 1000', () => { + expect(fsListRequestSchema.safeParse({ limit: 1001 }).success).toBe(false); + }); +}); + +describe('fsListResponseSchema', () => { + it('round-trips an empty truncated:false response', () => { + expect( + fsListResponseSchema.parse({ items: [], truncated: false }), + ).toEqual({ items: [], truncated: false }); + }); + + it('round-trips a response with children_by_path', () => { + const r = { + items: [ + { + path: 'src', + name: 'src', + kind: 'directory' as const, + modified_at: '2026-06-04T10:00:00.000Z', + child_count: 2, + }, + ], + children_by_path: { + src: [ + { + path: 'src/index.ts', + name: 'index.ts', + kind: 'file' as const, + size: 100, + modified_at: '2026-06-04T10:00:00.000Z', + }, + ], + }, + truncated: false, + }; + expect(fsListResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsReadRequestSchema', () => { + it('applies defaults', () => { + const parsed = fsReadRequestSchema.parse({ path: 'a.ts' }); + expect(parsed).toEqual({ + path: 'a.ts', + offset: 0, + length: 1_048_576, + encoding: 'auto', + }); + }); + + it('rejects length > 10 MB', () => { + expect( + fsReadRequestSchema.safeParse({ path: 'a', length: 10_485_761 }).success, + ).toBe(false); + }); + + it('rejects empty path', () => { + expect(fsReadRequestSchema.safeParse({ path: '' }).success).toBe(false); + }); + + it('rejects negative offset', () => { + expect( + fsReadRequestSchema.safeParse({ path: 'a', offset: -1 }).success, + ).toBe(false); + }); +}); + +describe('fsReadResponseSchema', () => { + it('round-trips a text response', () => { + const r = { + path: 'a.ts', + content: 'hello', + encoding: 'utf-8' as const, + size: 5, + truncated: false, + etag: 'deadbeef', + mime: 'text/typescript', + language_id: 'typescript', + line_count: 1, + is_binary: false, + }; + expect(fsReadResponseSchema.parse(r)).toEqual(r); + }); + + it('round-trips a base64 response with line_count omitted', () => { + const r = { + path: 'a.png', + content: 'iVBORw0KGgo=', + encoding: 'base64' as const, + size: 9, + truncated: false, + etag: 'cafebabe', + mime: 'image/png', + is_binary: true, + }; + expect(fsReadResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsListManyRequestSchema', () => { + it('requires at least one path', () => { + expect(fsListManyRequestSchema.safeParse({ paths: [] }).success).toBe(false); + }); + + it('caps paths at 100', () => { + const paths = Array.from({ length: 101 }, (_, i) => `p${i}`); + expect(fsListManyRequestSchema.safeParse({ paths }).success).toBe(false); + }); + + it('applies depth/limit defaults', () => { + const parsed = fsListManyRequestSchema.parse({ paths: ['a', 'b'] }); + expect(parsed.depth).toBe(1); + expect(parsed.limit).toBe(200); + }); +}); + +describe('fsListManyResponseSchema', () => { + it('round-trips a per-path results map with partial_errors', () => { + const r = { + results: { + src: [ + { + path: 'src/index.ts', + name: 'index.ts', + kind: 'file' as const, + size: 100, + modified_at: '2026-06-04T10:00:00.000Z', + }, + ], + }, + partial_errors: { + 'does/not/exist': { code: 40409, msg: 'fs.path_not_found' }, + }, + }; + expect(fsListManyResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsStatRequestSchema', () => { + it('requires non-empty path', () => { + expect(fsStatRequestSchema.safeParse({ path: '' }).success).toBe(false); + expect(fsStatRequestSchema.parse({ path: 'a' })).toEqual({ path: 'a' }); + }); +}); + +describe('fsStatManyRequestSchema', () => { + it('caps paths at 1000', () => { + const paths = Array.from({ length: 1001 }, (_, i) => `p${i}`); + expect(fsStatManyRequestSchema.safeParse({ paths }).success).toBe(false); + }); + + it('accepts exactly 1000 paths', () => { + const paths = Array.from({ length: 1000 }, (_, i) => `p${i}`); + expect(fsStatManyRequestSchema.safeParse({ paths }).success).toBe(true); + }); + + it('rejects empty paths array', () => { + expect(fsStatManyRequestSchema.safeParse({ paths: [] }).success).toBe(false); + }); +}); + +describe('fsStatManyResponseSchema', () => { + it('accepts null per-path entries (miss marker)', () => { + const r = { + entries: { + present: { + path: 'present', + name: 'present', + kind: 'file' as const, + modified_at: '2026-06-04T10:00:00.000Z', + }, + missing: null, + }, + }; + expect(fsStatManyResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsSearchRequestSchema (W11.1)', () => { + it('applies all defaults on minimal body', () => { + const parsed = fsSearchRequestSchema.parse({ query: 'Button' }); + expect(parsed).toEqual({ + query: 'Button', + limit: 50, + follow_gitignore: true, + }); + }); + + it('rejects empty query', () => { + expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false); + }); + + it('caps limit at 200', () => { + expect(fsSearchRequestSchema.safeParse({ query: 'a', limit: 201 }).success).toBe(false); + expect(fsSearchRequestSchema.safeParse({ query: 'a', limit: 200 }).success).toBe(true); + }); + + it('round-trips include / exclude globs', () => { + const body = { + query: 'a', + limit: 100, + include_globs: ['**/*.ts'], + exclude_globs: ['**/node_modules/**'], + follow_gitignore: false, + }; + expect(fsSearchRequestSchema.parse(body)).toEqual(body); + }); +}); + +describe('fsSearchResponseSchema (W11.1)', () => { + it('round-trips an empty response', () => { + expect(fsSearchResponseSchema.parse({ items: [], truncated: false })) + .toEqual({ items: [], truncated: false }); + }); + + it('round-trips a populated response', () => { + const r = { + items: [ + { + path: 'src/Button.tsx', + name: 'Button.tsx', + kind: 'file' as const, + score: 0.9, + match_positions: [4, 5, 6, 7, 8, 9], + }, + ], + truncated: true, + }; + expect(fsSearchResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsGrepRequestSchema (W11.1)', () => { + it('applies all REST.md §3.9 defaults', () => { + const parsed = fsGrepRequestSchema.parse({ pattern: 'hello' }); + expect(parsed).toEqual({ + pattern: 'hello', + regex: false, + case_sensitive: true, + follow_gitignore: true, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 2, + }); + }); + + it('rejects empty pattern', () => { + expect(fsGrepRequestSchema.safeParse({ pattern: '' }).success).toBe(false); + }); + + it('rejects context_lines > 10', () => { + expect( + fsGrepRequestSchema.safeParse({ pattern: 'a', context_lines: 11 }).success, + ).toBe(false); + }); + + it('accepts a regex pattern', () => { + const parsed = fsGrepRequestSchema.parse({ + pattern: 'foo|bar', + regex: true, + }); + expect(parsed.regex).toBe(true); + }); +}); + +describe('fsGrepResponseSchema (W11.1)', () => { + it('round-trips an empty response', () => { + expect( + fsGrepResponseSchema.parse({ + files: [], + files_scanned: 0, + truncated: false, + elapsed_ms: 12, + }), + ).toEqual({ files: [], files_scanned: 0, truncated: false, elapsed_ms: 12 }); + }); + + it('round-trips a populated response', () => { + const r = { + files: [ + { + path: 'src/index.ts', + matches: [ + { + line: 1, + col: 1, + text: 'console.log("hello");', + before: [], + after: [], + }, + ], + }, + ], + files_scanned: 42, + truncated: false, + elapsed_ms: 87, + }; + expect(fsGrepResponseSchema.parse(r)).toEqual(r); + }); +}); + +describe('fsGitStatusRequestSchema (W11.2)', () => { + it('accepts an empty body', () => { + expect(fsGitStatusRequestSchema.parse({})).toEqual({}); + }); + + it('accepts an explicit paths filter', () => { + const parsed = fsGitStatusRequestSchema.parse({ paths: ['a', 'b'] }); + expect(parsed.paths).toEqual(['a', 'b']); + }); + + it('rejects empty path strings inside paths', () => { + expect( + fsGitStatusRequestSchema.safeParse({ paths: [''] }).success, + ).toBe(false); + }); +}); + +describe('fsGitStatusResponseSchema (W11.2)', () => { + it('round-trips a clean tree response', () => { + const r = { + branch: 'main', + ahead: 0, + behind: 0, + entries: {}, + }; + expect(fsGitStatusResponseSchema.parse(r)).toEqual(r); + }); + + it('round-trips a dirty tree response', () => { + const r = { + branch: 'feat/web', + ahead: 2, + behind: 1, + entries: { + 'src/index.ts': 'modified' as const, + 'src/new.ts': 'untracked' as const, + 'src/old.ts': 'deleted' as const, + }, + }; + expect(fsGitStatusResponseSchema.parse(r)).toEqual(r); + }); + + it('accepts empty branch (detached HEAD)', () => { + expect( + fsGitStatusResponseSchema.parse({ + branch: '', + ahead: 0, + behind: 0, + entries: {}, + }).branch, + ).toBe(''); + }); +}); + +describe('fsDownloadParamsSchema (W11.3)', () => { + it('parses a minimal path', () => { + expect(fsDownloadParamsSchema.parse({ path: 'a.txt' })).toEqual({ + path: 'a.txt', + }); + }); + + it('parses range + if-none-match headers', () => { + const p = { + path: 'big.bin', + range: 'bytes=0-65535', + if_none_match: 'cafebabe', + }; + expect(fsDownloadParamsSchema.parse(p)).toEqual(p); + }); + + it('rejects empty path', () => { + expect(fsDownloadParamsSchema.safeParse({ path: '' }).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-message.test.ts b/packages/protocol/src/__tests__/rest-message.test.ts new file mode 100644 index 000000000..872aac800 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-message.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { + getMessageResponseSchema, + listMessagesQuerySchema, + listMessagesResponseSchema, +} from '../rest/message'; + +describe('listMessagesQuerySchema', () => { + it('accepts an empty query', () => { + expect(listMessagesQuerySchema.parse({})).toEqual({}); + }); + + it('accepts before_id + page_size + role', () => { + const parsed = listMessagesQuerySchema.parse({ + before_id: 'msg_abc', + page_size: 25, + role: 'assistant', + }); + expect(parsed.before_id).toBe('msg_abc'); + expect(parsed.page_size).toBe(25); + expect(parsed.role).toBe('assistant'); + }); + + it('rejects before_id + after_id together', () => { + expect( + listMessagesQuerySchema.safeParse({ before_id: 'a', after_id: 'b' }).success, + ).toBe(false); + }); + + it('rejects page_size > 100 (SCHEMAS §1.3 / REST §1.6)', () => { + expect(listMessagesQuerySchema.safeParse({ page_size: 101 }).success).toBe(false); + }); + + it('rejects unknown role values', () => { + expect(listMessagesQuerySchema.safeParse({ role: 'critter' }).success).toBe(false); + }); +}); + +describe('listMessagesResponseSchema', () => { + it('parses an empty page', () => { + expect(listMessagesResponseSchema.parse({ items: [], has_more: false })).toEqual({ + items: [], + has_more: false, + }); + }); + + it('parses a page with one message', () => { + const parsed = listMessagesResponseSchema.parse({ + items: [ + { + id: 'msg_01', + session_id: 'sess_1', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + created_at: '2026-06-04T10:30:00.000Z', + }, + ], + has_more: true, + }); + expect(parsed.items).toHaveLength(1); + expect(parsed.has_more).toBe(true); + }); +}); + +describe('getMessageResponseSchema', () => { + it('parses a Message with optional fields', () => { + const parsed = getMessageResponseSchema.parse({ + id: 'msg_01', + session_id: 'sess_1', + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + created_at: '2026-06-04T10:30:00.000Z', + prompt_id: 'prompt_01', + }); + expect(parsed.prompt_id).toBe('prompt_01'); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-meta.test.ts b/packages/protocol/src/__tests__/rest-meta.test.ts new file mode 100644 index 000000000..c64e8e6ac --- /dev/null +++ b/packages/protocol/src/__tests__/rest-meta.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { metaResponseSchema, type MetaResponse } from '../rest/meta'; + +describe('metaResponseSchema', () => { + const sample = { + daemon_version: '0.1.0', + capabilities: { + websocket: true, + file_upload: true, + fs_query: true, + mcp: true, + background_tasks: true, + }, + daemon_id: '01HXYZABCDEFGHJKMNPQRSTVWX', + started_at: '2026-06-04T10:30:00.000Z', + }; + + it('round-trips a well-formed payload', () => { + const parsed: MetaResponse = metaResponseSchema.parse(sample); + expect(parsed.daemon_version).toBe('0.1.0'); + expect(parsed.capabilities.websocket).toBe(true); + expect(parsed.daemon_id).toBe('01HXYZABCDEFGHJKMNPQRSTVWX'); + expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z'); + }); + + it('normalizes started_at to UTC Z with millisecond precision', () => { + const offsetForm = { + ...sample, + started_at: '2026-06-04T18:30:00+08:00', + }; + const parsed = metaResponseSchema.parse(offsetForm); + expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z'); + }); + + it('rejects missing daemon_version', () => { + const { daemon_version: _omit, ...rest } = sample; + expect(metaResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing capabilities', () => { + const { capabilities: _omit, ...rest } = sample; + expect(metaResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing daemon_id', () => { + const { daemon_id: _omit, ...rest } = sample; + expect(metaResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing started_at', () => { + const { started_at: _omit, ...rest } = sample; + expect(metaResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects a capability set with the wrong boolean literal', () => { + const bad = { + ...sample, + capabilities: { ...sample.capabilities, websocket: false }, + }; + expect(metaResponseSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects an empty daemon_version string', () => { + const bad = { ...sample, daemon_version: '' }; + expect(metaResponseSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects a malformed started_at (no timezone marker)', () => { + const bad = { ...sample, started_at: '2026-06-04T10:30:00' }; + expect(metaResponseSchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-prompt.test.ts b/packages/protocol/src/__tests__/rest-prompt.test.ts new file mode 100644 index 000000000..c82a6f88f --- /dev/null +++ b/packages/protocol/src/__tests__/rest-prompt.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; + +import { + promptAbortResponseSchema, + promptListResponseSchema, + promptSubmissionSchema, + promptSubmitResultSchema, + promptSteerRequestSchema, + promptSteerResultSchema, +} from '../rest/prompt'; + +describe('promptSubmissionSchema', () => { + it('accepts a minimal text-only submission with no controls', () => { + const parsed = promptSubmissionSchema.parse({ + content: [{ type: 'text', text: 'hi' }], + }); + expect(parsed.content[0]?.type).toBe('text'); + expect(parsed.model).toBeUndefined(); + expect(parsed.thinking).toBeUndefined(); + expect(parsed.permission_mode).toBeUndefined(); + expect(parsed.plan_mode).toBeUndefined(); + }); + + it('accepts metadata', () => { + const parsed = promptSubmissionSchema.parse({ + content: [{ type: 'text', text: 'hi' }], + metadata: { source: 'cli' }, + }); + expect(parsed.metadata).toEqual({ source: 'cli' }); + }); + + it('accepts image + text mixed content', () => { + const parsed = promptSubmissionSchema.parse({ + content: [ + { type: 'text', text: 'see attached' }, + { type: 'image', source: { kind: 'url', url: 'https://a.png' } }, + ], + }); + expect(parsed.content).toHaveLength(2); + }); + + it('accepts a partial per-turn override (model only)', () => { + const parsed = promptSubmissionSchema.parse({ + content: [{ type: 'text', text: 'hi' }], + model: 'kimi-code/k2', + }); + expect(parsed.model).toBe('kimi-code/k2'); + expect(parsed.thinking).toBeUndefined(); + }); + + it('accepts the full bundle of controls when supplied', () => { + const parsed = promptSubmissionSchema.parse({ + content: [{ type: 'text', text: 'hi' }], + model: 'kimi-code/k2', + thinking: 'off', + permission_mode: 'manual', + plan_mode: false, + }); + expect(parsed.model).toBe('kimi-code/k2'); + expect(parsed.thinking).toBe('off'); + expect(parsed.permission_mode).toBe('manual'); + expect(parsed.plan_mode).toBe(false); + }); + + it('rejects empty content array', () => { + expect( + promptSubmissionSchema.safeParse({ + content: [], + }).success, + ).toBe(false); + }); + + it('rejects missing content', () => { + expect(promptSubmissionSchema.safeParse({} as unknown).success).toBe(false); + }); + + it('rejects unknown thinking level', () => { + expect( + promptSubmissionSchema.safeParse({ + content: [{ type: 'text', text: 'hi' }], + thinking: 'mega' as unknown, + }).success, + ).toBe(false); + }); + + it('rejects unknown permission_mode', () => { + expect( + promptSubmissionSchema.safeParse({ + content: [{ type: 'text', text: 'hi' }], + permission_mode: 'unrestricted' as unknown, + }).success, + ).toBe(false); + }); + + it('rejects empty model string', () => { + expect( + promptSubmissionSchema.safeParse({ + content: [{ type: 'text', text: 'hi' }], + model: '', + }).success, + ).toBe(false); + }); +}); + +describe('promptSubmitResultSchema', () => { + it('parses a running prompt result shape', () => { + const parsed = promptSubmitResultSchema.parse({ + prompt_id: 'prompt_01HZ', + user_message_id: 'msg_sess_01_000000', + status: 'running', + content: [{ type: 'text', text: 'hi' }], + created_at: '2026-06-09T00:00:00.000Z', + }); + expect(parsed.prompt_id).toBe('prompt_01HZ'); + expect(parsed.status).toBe('running'); + }); + + it('rejects empty prompt_id', () => { + expect( + promptSubmitResultSchema.safeParse({ prompt_id: '', user_message_id: 'msg' }) + .success, + ).toBe(false); + }); +}); + +describe('promptListResponseSchema', () => { + it('parses active and queued prompts', () => { + const parsed = promptListResponseSchema.parse({ + active: { + prompt_id: 'prompt_active', + user_message_id: 'msg_active', + status: 'running', + content: [{ type: 'text', text: 'active' }], + created_at: '2026-06-09T00:00:00.000Z', + }, + queued: [ + { + prompt_id: 'prompt_queued', + user_message_id: 'msg_queued', + status: 'queued', + content: [{ type: 'text', text: 'queued' }], + created_at: '2026-06-09T00:00:01.000Z', + }, + ], + }); + expect(parsed.active?.status).toBe('running'); + expect(parsed.queued[0]?.status).toBe('queued'); + }); +}); + +describe('promptSteerRequestSchema', () => { + it('requires at least one prompt id', () => { + expect(promptSteerRequestSchema.parse({ prompt_ids: ['prompt_a'] }).prompt_ids) + .toEqual(['prompt_a']); + expect(promptSteerRequestSchema.safeParse({ prompt_ids: [] }).success).toBe(false); + }); +}); + +describe('promptSteerResultSchema', () => { + it('parses steered prompt ids', () => { + const parsed = promptSteerResultSchema.parse({ + steered: true, + prompt_ids: ['prompt_a', 'prompt_b'], + }); + expect(parsed.steered).toBe(true); + expect(parsed.prompt_ids).toEqual(['prompt_a', 'prompt_b']); + }); +}); + +describe('promptAbortResponseSchema', () => { + it('parses { aborted: true } success shape', () => { + const parsed = promptAbortResponseSchema.parse({ aborted: true, at_seq: 7 }); + expect(parsed.aborted).toBe(true); + expect(parsed.at_seq).toBe(7); + }); + + it('parses { aborted: false } idempotent shape (used with envelope.code=40903)', () => { + const parsed = promptAbortResponseSchema.parse({ aborted: false }); + expect(parsed.aborted).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts new file mode 100644 index 000000000..9329001ff --- /dev/null +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from 'vitest'; + +import { + compactSessionRequestSchema, + compactSessionResponseSchema, + createSessionChildRequestSchema, + createSessionChildResponseSchema, + createSessionRequestSchema, + deleteSessionResponseSchema, + forkSessionRequestSchema, + forkSessionResponseSchema, + getSessionProfileResponseSchema, + listSessionChildrenResponseSchema, + listSessionsQuerySchema, + sessionStatusResponseSchema, + updateSessionProfileRequestSchema, + updateSessionRequestSchema, + undoSessionRequestSchema, + undoSessionResponseSchema, +} from '../rest/session'; + +describe('createSessionRequestSchema', () => { + it('accepts a minimal POST body with metadata.cwd', () => { + const parsed = createSessionRequestSchema.parse({ metadata: { cwd: '/tmp/foo' } }); + expect(parsed.metadata?.cwd).toBe('/tmp/foo'); + }); + + it('accepts a POST body with only workspace_id (route layer resolves cwd)', () => { + const parsed = createSessionRequestSchema.parse({ + workspace_id: 'wd_kimi_0123456789ab', + }); + expect(parsed.workspace_id).toBe('wd_kimi_0123456789ab'); + expect(parsed.metadata).toBeUndefined(); + }); + + it('rejects metadata without cwd', () => { + expect( + createSessionRequestSchema.safeParse({ metadata: {} } as unknown).success, + ).toBe(false); + }); + + it('rejects extra unknown agent_config keys via partial schema (zod is permissive but the partial holds known keys)', () => { + const parsed = createSessionRequestSchema.parse({ + metadata: { cwd: '/tmp/foo' }, + agent_config: { model: 'm', unknown_key: 'x' } as unknown as { model: string }, + }); + expect(parsed.agent_config?.model).toBe('m'); + expect((parsed.agent_config as Record)['unknown_key']).toBeUndefined(); + }); +}); + +describe('listSessionsQuerySchema', () => { + it('accepts an empty query (defaults applied at handler layer)', () => { + expect(listSessionsQuerySchema.parse({})).toEqual({}); + }); + + it('accepts before_id + page_size', () => { + const parsed = listSessionsQuerySchema.parse({ before_id: 'sess_abc', page_size: 20 }); + expect(parsed.before_id).toBe('sess_abc'); + expect(parsed.page_size).toBe(20); + }); + + it('rejects before_id + after_id together (REST §1.6 mutual exclusivity)', () => { + const result = listSessionsQuerySchema.safeParse({ + before_id: 'a', + after_id: 'b', + }); + expect(result.success).toBe(false); + }); + + it('rejects page_size > 100', () => { + expect(listSessionsQuerySchema.safeParse({ page_size: 101 }).success).toBe(false); + }); + + it('accepts a status filter', () => { + expect(listSessionsQuerySchema.parse({ status: 'idle' })).toEqual({ status: 'idle' }); + }); + + it('rejects an unknown status value', () => { + expect(listSessionsQuerySchema.safeParse({ status: 'frozen' }).success).toBe(false); + }); +}); + +describe('getSessionProfileResponseSchema', () => { + it('accepts a Session payload', () => { + const parsed = getSessionProfileResponseSchema.parse({ + id: 'sess_abc', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Profile', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + metadata: { cwd: '/tmp/foo' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }); + expect(parsed.id).toBe('sess_abc'); + }); +}); + +describe('updateSessionProfileRequestSchema', () => { + it('accepts a metadata patch (without cwd)', () => { + expect( + updateSessionProfileRequestSchema.parse({ metadata: { custom_field: 'x' } }), + ).toEqual({ metadata: { custom_field: 'x' } }); + }); + + it('accepts an empty POST body (no-op)', () => { + expect(updateSessionProfileRequestSchema.parse({})).toEqual({}); + }); + + it('accepts agent_config.model', () => { + const parsed = updateSessionProfileRequestSchema.parse({ + agent_config: { model: 'moonshot-v1-128k' }, + }); + expect(parsed.agent_config?.model).toBe('moonshot-v1-128k'); + }); + + it('accepts agent_config runtime controls (thinking + permission_mode + plan_mode)', () => { + const parsed = updateSessionProfileRequestSchema.parse({ + agent_config: { + thinking: 'medium', + permission_mode: 'auto', + plan_mode: false, + }, + }); + expect(parsed.agent_config).toEqual({ + thinking: 'medium', + permission_mode: 'auto', + plan_mode: false, + }); + }); +}); + +describe('updateSessionRequestSchema (legacy alias)', () => { + it('round-trips through the same schema as updateSessionProfileRequestSchema', () => { + expect(updateSessionRequestSchema.parse({ metadata: { custom_field: 'x' } })).toEqual( + updateSessionProfileRequestSchema.parse({ metadata: { custom_field: 'x' } }), + ); + }); +}); + +describe('forkSessionRequestSchema', () => { + it('accepts an empty POST body', () => { + expect(forkSessionRequestSchema.parse({})).toEqual({}); + }); + + it('accepts title and arbitrary metadata without requiring cwd', () => { + const parsed = forkSessionRequestSchema.parse({ + title: 'Fork: source', + metadata: { origin: 'web', depth: 1 }, + }); + expect(parsed).toEqual({ + title: 'Fork: source', + metadata: { origin: 'web', depth: 1 }, + }); + }); + + it('rejects non-object metadata', () => { + expect(forkSessionRequestSchema.safeParse({ metadata: 'x' }).success).toBe(false); + }); +}); + +describe('forkSessionResponseSchema', () => { + it('accepts a Session payload', () => { + const parsed = forkSessionResponseSchema.parse({ + id: 'sess_fork', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Fork: source', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + metadata: { cwd: '/tmp/foo', origin: 'web' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }); + expect(parsed.id).toBe('sess_fork'); + }); +}); + +describe('createSessionChildRequestSchema', () => { + it('accepts title and arbitrary metadata without requiring cwd', () => { + const parsed = createSessionChildRequestSchema.parse({ + title: 'Side question', + metadata: { origin: 'web', topic: 'btw' }, + }); + expect(parsed).toEqual({ + title: 'Side question', + metadata: { origin: 'web', topic: 'btw' }, + }); + }); + + it('rejects non-object metadata', () => { + expect(createSessionChildRequestSchema.safeParse({ metadata: 'x' }).success).toBe(false); + }); +}); + +describe('createSessionChildResponseSchema', () => { + it('accepts a Session payload', () => { + const parsed = createSessionChildResponseSchema.parse({ + id: 'sess_child', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Child: source', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + metadata: { cwd: '/tmp/foo', parent_session_id: 'sess_parent' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }); + expect(parsed.metadata['parent_session_id']).toBe('sess_parent'); + }); +}); + +describe('listSessionChildrenResponseSchema', () => { + it('accepts a paged list of child sessions', () => { + const parsed = listSessionChildrenResponseSchema.parse({ + items: [ + { + id: 'sess_child', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Child: source', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + metadata: { cwd: '/tmp/foo', parent_session_id: 'sess_parent' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }, + ], + has_more: false, + }); + expect(parsed.items).toHaveLength(1); + }); +}); + +describe('sessionStatusResponseSchema', () => { + it('accepts a full valid shape', () => { + const parsed = sessionStatusResponseSchema.parse({ + model: 'moonshot-v1-128k', + thinking_level: 'on', + permission: 'ask', + plan_mode: true, + context_tokens: 1024, + max_context_tokens: 128000, + context_usage: 0.008, + }); + expect(parsed.model).toBe('moonshot-v1-128k'); + expect(parsed.plan_mode).toBe(true); + expect(parsed.context_usage).toBe(0.008); + }); + + it('accepts minimal shape without model', () => { + const parsed = sessionStatusResponseSchema.parse({ + thinking_level: 'off', + permission: 'auto', + plan_mode: false, + context_tokens: 0, + max_context_tokens: 0, + context_usage: 0, + }); + expect(parsed.model).toBeUndefined(); + }); + + it('rejects negative context_tokens', () => { + expect( + sessionStatusResponseSchema.safeParse({ + thinking_level: 'off', + permission: 'auto', + plan_mode: false, + context_tokens: -1, + max_context_tokens: 0, + context_usage: 0, + }).success, + ).toBe(false); + }); + + it('rejects context_usage > 1', () => { + expect( + sessionStatusResponseSchema.safeParse({ + thinking_level: 'off', + permission: 'auto', + plan_mode: false, + context_tokens: 10, + max_context_tokens: 5, + context_usage: 2, + }).success, + ).toBe(false); + }); +}); + +describe('compactSessionRequestSchema', () => { + it('accepts an empty body', () => { + expect(compactSessionRequestSchema.parse({})).toEqual({}); + }); + + it('treats a missing body as empty', () => { + expect(compactSessionRequestSchema.parse(undefined)).toEqual({}); + }); + + it('accepts an optional instruction string', () => { + expect(compactSessionRequestSchema.parse({ instruction: ' focus on decisions ' })).toEqual({ + instruction: ' focus on decisions ', + }); + }); + + it('rejects a non-string instruction', () => { + expect(compactSessionRequestSchema.safeParse({ instruction: 123 }).success).toBe(false); + }); +}); + +describe('compactSessionResponseSchema', () => { + it('accepts the empty success payload', () => { + expect(compactSessionResponseSchema.parse({})).toEqual({}); + }); +}); + +describe('undoSessionRequestSchema', () => { + it('defaults a missing body to undoing one prompt', () => { + expect(undoSessionRequestSchema.parse(undefined)).toEqual({ count: 1 }); + }); + + it('accepts a positive count and bounded page size', () => { + expect(undoSessionRequestSchema.parse({ count: 2, page_size: 25 })).toEqual({ + count: 2, + page_size: 25, + }); + }); + + it('rejects zero count and oversized page size', () => { + expect(undoSessionRequestSchema.safeParse({ count: 0 }).success).toBe(false); + expect(undoSessionRequestSchema.safeParse({ page_size: 101 }).success).toBe(false); + }); +}); + +describe('undoSessionResponseSchema', () => { + it('accepts messages plus the refreshed session status', () => { + const parsed = undoSessionResponseSchema.parse({ + messages: { + items: [ + { + id: 'msg_sess_abc_000000', + session_id: 'sess_abc', + role: 'user', + content: [{ type: 'text', text: 'kept' }], + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + has_more: false, + }, + status: { + model: 'kimi-k2', + thinking_level: 'auto', + permission: 'manual', + plan_mode: false, + context_tokens: 10, + max_context_tokens: 100, + context_usage: 0.1, + }, + }); + expect(parsed.messages.items).toHaveLength(1); + expect(parsed.status.context_tokens).toBe(10); + }); +}); + +describe('deleteSessionResponseSchema', () => { + it('accepts the canonical { deleted: true } shape', () => { + expect(deleteSessionResponseSchema.parse({ deleted: true })).toEqual({ deleted: true }); + }); + + it('rejects { deleted: false }', () => { + expect(deleteSessionResponseSchema.safeParse({ deleted: false }).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-task.test.ts b/packages/protocol/src/__tests__/rest-task.test.ts new file mode 100644 index 000000000..3fef8945f --- /dev/null +++ b/packages/protocol/src/__tests__/rest-task.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { + cancelTaskResultSchema, + getTaskQuerySchema, + getTaskResponseSchema, + listTasksQuerySchema, + listTasksResponseSchema, + taskAlreadyFinishedDataSchema, +} from '../rest/task'; + +describe('listTasksQuerySchema', () => { + it('accepts empty query', () => { + expect(listTasksQuerySchema.parse({})).toEqual({}); + }); + it('accepts status filter', () => { + expect(listTasksQuerySchema.parse({ status: 'running' })).toEqual({ + status: 'running', + }); + }); + it('rejects unknown status', () => { + expect(listTasksQuerySchema.safeParse({ status: 'pending' }).success).toBe(false); + }); +}); + +describe('listTasksResponseSchema', () => { + it('round-trips empty items[]', () => { + expect(listTasksResponseSchema.parse({ items: [] })).toEqual({ items: [] }); + }); +}); + +describe('getTaskQuerySchema', () => { + it('accepts empty query', () => { + expect(getTaskQuerySchema.parse({})).toEqual({}); + }); + it('coerces with_output + output_bytes from strings (HTTP query)', () => { + const parsed = getTaskQuerySchema.parse({ with_output: 'true', output_bytes: '512' }); + expect(parsed.with_output).toBe(true); + expect(parsed.output_bytes).toBe(512); + }); +}); + +describe('getTaskResponseSchema', () => { + it('parses a minimal task shape', () => { + const t = { + id: 'task_01', + session_id: 'sess_01', + kind: 'subagent' as const, + description: 'spin up x', + status: 'running' as const, + created_at: '2026-06-04T10:00:00.000Z', + }; + expect(getTaskResponseSchema.parse(t).kind).toBe('subagent'); + }); +}); + +describe('cancelTaskResultSchema', () => { + it('requires cancelled: true literal', () => { + expect(cancelTaskResultSchema.parse({ cancelled: true })).toEqual({ cancelled: true }); + expect(cancelTaskResultSchema.safeParse({ cancelled: false }).success).toBe(false); + }); +}); + +describe('taskAlreadyFinishedDataSchema (40904 envelope data)', () => { + it('requires cancelled: false literal', () => { + expect(taskAlreadyFinishedDataSchema.parse({ cancelled: false })).toEqual({ + cancelled: false, + }); + expect(taskAlreadyFinishedDataSchema.safeParse({ cancelled: true }).success).toBe( + false, + ); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-tool.test.ts b/packages/protocol/src/__tests__/rest-tool.test.ts new file mode 100644 index 000000000..21669623c --- /dev/null +++ b/packages/protocol/src/__tests__/rest-tool.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + listMcpServersResponseSchema, + listToolsQuerySchema, + listToolsResponseSchema, + restartMcpServerResultSchema, +} from '../rest/tool'; + +describe('listToolsQuerySchema', () => { + it('accepts an empty query (global tool list)', () => { + expect(listToolsQuerySchema.parse({})).toEqual({}); + }); + + it('accepts session_id (session-effective tool list)', () => { + expect(listToolsQuerySchema.parse({ session_id: 'sess_01' })).toEqual({ + session_id: 'sess_01', + }); + }); + + it('rejects an empty session_id string', () => { + expect(listToolsQuerySchema.safeParse({ session_id: '' }).success).toBe(false); + }); +}); + +describe('listToolsResponseSchema', () => { + it('round-trips a list of tools', () => { + const payload = { + tools: [ + { + name: 'Bash', + description: 'Execute shell', + input_schema: null, + source: 'builtin' as const, + }, + ], + }; + expect(listToolsResponseSchema.parse(payload).tools).toHaveLength(1); + }); + + it('accepts an empty tools array', () => { + expect(listToolsResponseSchema.parse({ tools: [] })).toEqual({ tools: [] }); + }); +}); + +describe('listMcpServersResponseSchema', () => { + it('round-trips a list of servers', () => { + const payload = { + servers: [ + { + id: 'lark', + name: 'lark', + transport: 'stdio' as const, + status: 'connected' as const, + tool_count: 3, + }, + ], + }; + expect(listMcpServersResponseSchema.parse(payload).servers[0]!.id).toBe('lark'); + }); +}); + +describe('restartMcpServerResultSchema', () => { + it('requires restarting: true literal', () => { + expect(restartMcpServerResultSchema.parse({ restarting: true })).toEqual({ + restarting: true, + }); + expect(restartMcpServerResultSchema.safeParse({ restarting: false }).success).toBe( + false, + ); + }); +}); diff --git a/packages/protocol/src/__tests__/rest-workspace.test.ts b/packages/protocol/src/__tests__/rest-workspace.test.ts new file mode 100644 index 000000000..6f53ea141 --- /dev/null +++ b/packages/protocol/src/__tests__/rest-workspace.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { + createWorkspaceRequestSchema, + deleteWorkspaceResponseSchema, + listWorkspacesResponseSchema, + updateWorkspaceRequestSchema, + workspaceIdParamSchema, +} from '../rest/workspace'; +import { workspaceIdSchema, workspaceSchema, type Workspace } from '../workspace'; + +const sampleWorkspace: Workspace = { + id: 'wd_kimi-code_0123456789ab', + root: '/Users/foo/code/kimi-code', + name: 'kimi-code', + is_git_repo: true, + branch: 'main', + created_at: '2026-06-08T09:00:00.000Z', + last_opened_at: '2026-06-08T09:30:00.000Z', + session_count: 3, +}; + +describe('workspaceIdSchema', () => { + it('accepts a wd__ string', () => { + expect(workspaceIdSchema.parse('wd_kimi_0123456789ab')).toBe('wd_kimi_0123456789ab'); + }); + + it('accepts dots, dashes, underscores in slug', () => { + expect(workspaceIdSchema.parse('wd_kimi-code.v2_0123456789ab')).toBe( + 'wd_kimi-code.v2_0123456789ab', + ); + }); + + it('rejects missing wd_ prefix', () => { + expect(workspaceIdSchema.safeParse('kimi_0123456789ab').success).toBe(false); + }); + + it('rejects non-hex tail', () => { + expect(workspaceIdSchema.safeParse('wd_kimi_xyzxyzxyzxyz').success).toBe(false); + }); + + it('rejects truncated hash', () => { + expect(workspaceIdSchema.safeParse('wd_kimi_0123456789').success).toBe(false); + }); +}); + +describe('workspaceSchema', () => { + it('round-trips a fully populated Workspace', () => { + expect(workspaceSchema.parse(sampleWorkspace)).toEqual(sampleWorkspace); + }); + + it('accepts branch=null (detached HEAD / no git)', () => { + const detached = { ...sampleWorkspace, is_git_repo: true, branch: null }; + expect(workspaceSchema.parse(detached).branch).toBeNull(); + }); + + it('rejects name longer than 100 chars', () => { + const tooLong = { ...sampleWorkspace, name: 'x'.repeat(101) }; + expect(workspaceSchema.safeParse(tooLong).success).toBe(false); + }); +}); + +describe('createWorkspaceRequestSchema (POST /api/v1/workspaces)', () => { + it('accepts a root-only body', () => { + expect(createWorkspaceRequestSchema.parse({ root: '/Users/foo/code' })).toEqual({ + root: '/Users/foo/code', + }); + }); + + it('accepts root + name override', () => { + const parsed = createWorkspaceRequestSchema.parse({ + root: '/Users/foo/code', + name: 'Frontend Project', + }); + expect(parsed.name).toBe('Frontend Project'); + }); + + it('rejects empty root', () => { + expect(createWorkspaceRequestSchema.safeParse({ root: '' }).success).toBe(false); + }); + + it('rejects missing root', () => { + expect(createWorkspaceRequestSchema.safeParse({} as unknown).success).toBe(false); + }); + + it('rejects empty name', () => { + expect( + createWorkspaceRequestSchema.safeParse({ root: '/Users/foo/code', name: '' }).success, + ).toBe(false); + }); + + it('rejects name longer than 100 chars', () => { + expect( + createWorkspaceRequestSchema.safeParse({ + root: '/Users/foo/code', + name: 'x'.repeat(101), + }).success, + ).toBe(false); + }); +}); + +describe('updateWorkspaceRequestSchema (PATCH /api/v1/workspaces/{id})', () => { + it('accepts a name patch', () => { + expect(updateWorkspaceRequestSchema.parse({ name: 'Renamed' })).toEqual({ + name: 'Renamed', + }); + }); + + it('rejects empty body (name is required for the patch)', () => { + expect(updateWorkspaceRequestSchema.safeParse({} as unknown).success).toBe(false); + }); +}); + +describe('workspaceIdParamSchema', () => { + it('accepts a wd_-shaped workspace_id', () => { + expect( + workspaceIdParamSchema.parse({ workspace_id: 'wd_kimi_0123456789ab' }).workspace_id, + ).toBe('wd_kimi_0123456789ab'); + }); + + it('rejects a non-wd-shaped id', () => { + expect( + workspaceIdParamSchema.safeParse({ workspace_id: 'sess_abc' }).success, + ).toBe(false); + }); +}); + +describe('listWorkspacesResponseSchema', () => { + it('accepts an empty list', () => { + expect(listWorkspacesResponseSchema.parse({ items: [] })).toEqual({ items: [] }); + }); + + it('accepts a non-empty list', () => { + expect( + listWorkspacesResponseSchema.parse({ items: [sampleWorkspace] }).items[0]?.id, + ).toBe(sampleWorkspace.id); + }); +}); + +describe('deleteWorkspaceResponseSchema', () => { + it('accepts {deleted:true}', () => { + expect(deleteWorkspaceResponseSchema.parse({ deleted: true })).toEqual({ + deleted: true, + }); + }); + + it('rejects {deleted:false}', () => { + expect(deleteWorkspaceResponseSchema.safeParse({ deleted: false }).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/session.test.ts b/packages/protocol/src/__tests__/session.test.ts new file mode 100644 index 000000000..33dfa375c --- /dev/null +++ b/packages/protocol/src/__tests__/session.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { + emptySessionUsage, + permissionRuleSchema, + sessionCreateSchema, + sessionSchema, + sessionStatusSchema, + sessionUpdateSchema, + sessionUsageSchema, + type Session, +} from '../session'; + +describe('sessionStatusSchema', () => { + it.each(['idle', 'running', 'awaiting_approval', 'awaiting_question', 'aborted'] as const)( + 'accepts %s', + (status) => { + expect(sessionStatusSchema.parse(status)).toBe(status); + }, + ); + + it('rejects unknown status', () => { + expect(sessionStatusSchema.safeParse('chilling').success).toBe(false); + }); +}); + +describe('sessionUsageSchema + emptySessionUsage', () => { + it('emptySessionUsage is parseable as zero usage', () => { + const parsed = sessionUsageSchema.parse(emptySessionUsage()); + expect(parsed.input_tokens).toBe(0); + expect(parsed.context_limit).toBe(0); + expect(parsed.total_cost_usd).toBe(0); + }); + + it('rejects negative token counts', () => { + const bad = { ...emptySessionUsage(), input_tokens: -1 }; + expect(sessionUsageSchema.safeParse(bad).success).toBe(false); + }); +}); + +describe('permissionRuleSchema', () => { + const sample = { + id: 'rule_01', + tool_name: 'Bash', + matcher: { kind: 'always' as const }, + decision: 'approved' as const, + created_at: '2026-06-04T10:30:00.000Z', + created_by: 'user' as const, + }; + + it('parses an always-approve rule', () => { + expect(permissionRuleSchema.parse(sample).tool_name).toBe('Bash'); + }); + + it('rejects decision != approved (first-version invariant)', () => { + const bad = { ...sample, decision: 'rejected' }; + expect(permissionRuleSchema.safeParse(bad).success).toBe(false); + }); +}); + +describe('sessionSchema', () => { + const fullSession: Session = { + id: '01HXYZABCDEFGHJKMNPQRSTVWX', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Test session', + created_at: '2026-06-04T10:30:00.000Z', + updated_at: '2026-06-04T10:35:00.000Z', + status: 'idle', + metadata: { cwd: '/tmp/test' }, + agent_config: { model: 'moonshot-v1-128k' }, + usage: emptySessionUsage(), + permission_rules: [], + message_count: 0, + last_seq: 0, + }; + + it('round-trips a full Session', () => { + expect(sessionSchema.parse(fullSession)).toEqual(fullSession); + }); + + it('accepts arbitrary metadata extensions via catchall', () => { + const withExtras = { + ...fullSession, + metadata: { cwd: '/tmp/test', custom_flag: 'on', nested: { a: 1 } }, + }; + expect(sessionSchema.parse(withExtras).metadata['cwd']).toBe('/tmp/test'); + }); + + it('rejects when metadata.cwd is missing', () => { + const bad = { ...fullSession, metadata: {} }; + expect(sessionSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects when workspace_id is missing', () => { + const { workspace_id: _drop, ...bad } = fullSession; + expect(sessionSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects malformed workspace_id (not wd_ shape)', () => { + const bad = { ...fullSession, workspace_id: 'workspace_123' }; + expect(sessionSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects malformed created_at (no timezone)', () => { + const bad = { ...fullSession, created_at: '2026-06-04T10:30:00' }; + expect(sessionSchema.safeParse(bad).success).toBe(false); + }); + + it('normalizes timestamp offsets to UTC Z', () => { + const offsetForm = { ...fullSession, created_at: '2026-06-04T18:30:00+08:00' }; + const parsed = sessionSchema.parse(offsetForm); + expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z'); + }); +}); + +describe('sessionCreateSchema', () => { + it('parses a minimal create with metadata.cwd only', () => { + expect( + sessionCreateSchema.parse({ + metadata: { cwd: '/tmp/test' }, + }), + ).toEqual({ metadata: { cwd: '/tmp/test' } }); + }); + + it('parses a create with workspace_id only', () => { + expect( + sessionCreateSchema.parse({ + workspace_id: 'wd_kimi_0123456789ab', + }), + ).toEqual({ workspace_id: 'wd_kimi_0123456789ab' }); + }); + + it('parses a create with BOTH workspace_id and metadata.cwd (route layer enforces agreement)', () => { + const parsed = sessionCreateSchema.parse({ + workspace_id: 'wd_kimi_0123456789ab', + metadata: { cwd: '/tmp/test' }, + }); + expect(parsed.workspace_id).toBe('wd_kimi_0123456789ab'); + expect(parsed.metadata?.cwd).toBe('/tmp/test'); + }); + + it('parses a full create with title + agent_config', () => { + const parsed = sessionCreateSchema.parse({ + title: 'My session', + metadata: { cwd: '/tmp/test' }, + agent_config: { model: 'moonshot-v1-128k' }, + }); + expect(parsed.title).toBe('My session'); + expect(parsed.agent_config?.model).toBe('moonshot-v1-128k'); + }); + + it('accepts an entirely empty body (route layer rejects when neither workspace_id nor metadata.cwd is present)', () => { + expect(sessionCreateSchema.safeParse({}).success).toBe(true); + }); + + it('rejects malformed workspace_id', () => { + expect( + sessionCreateSchema.safeParse({ workspace_id: 'not-a-wd-key' }).success, + ).toBe(false); + }); + + it('rejects metadata without cwd', () => { + expect(sessionCreateSchema.safeParse({ metadata: {} }).success).toBe(false); + }); +}); + +describe('sessionUpdateSchema', () => { + it('parses a title-only update', () => { + expect(sessionUpdateSchema.parse({ title: 'Renamed' })).toEqual({ title: 'Renamed' }); + }); + + it('parses a permission_rules full-replacement (including empty array = clear)', () => { + expect(sessionUpdateSchema.parse({ permission_rules: [] })).toEqual({ + permission_rules: [], + }); + }); + + it('parses a partial agent_config patch', () => { + expect( + sessionUpdateSchema.parse({ agent_config: { model: 'moonshot-v1-256k' } }), + ).toEqual({ agent_config: { model: 'moonshot-v1-256k' } }); + }); + + it('parses a runtime-controls patch (thinking + permission_mode + plan_mode)', () => { + const parsed = sessionUpdateSchema.parse({ + agent_config: { + thinking: 'high', + permission_mode: 'yolo', + plan_mode: true, + }, + }); + expect(parsed.agent_config).toEqual({ + thinking: 'high', + permission_mode: 'yolo', + plan_mode: true, + }); + }); + + it('rejects an unknown thinking level in agent_config', () => { + expect( + sessionUpdateSchema.safeParse({ + agent_config: { thinking: 'mega' as unknown }, + }).success, + ).toBe(false); + }); + + it('rejects an unknown permission_mode in agent_config', () => { + expect( + sessionUpdateSchema.safeParse({ + agent_config: { permission_mode: 'unrestricted' as unknown }, + }).success, + ).toBe(false); + }); + + it('parses an empty update (no-op)', () => { + expect(sessionUpdateSchema.parse({})).toEqual({}); + }); +}); diff --git a/packages/protocol/src/__tests__/task.test.ts b/packages/protocol/src/__tests__/task.test.ts new file mode 100644 index 000000000..144c19199 --- /dev/null +++ b/packages/protocol/src/__tests__/task.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + backgroundTaskKindSchema, + backgroundTaskSchema, + backgroundTaskStatusSchema, + type BackgroundTask, +} from '../task'; + +describe('backgroundTaskKindSchema', () => { + it.each(['subagent', 'bash', 'tool'] as const)('accepts %s', (k) => { + expect(backgroundTaskKindSchema.parse(k)).toBe(k); + }); + + it("rejects agent-core's 'process' / 'agent' / 'question' literals", () => { + expect(backgroundTaskKindSchema.safeParse('process').success).toBe(false); + expect(backgroundTaskKindSchema.safeParse('agent').success).toBe(false); + expect(backgroundTaskKindSchema.safeParse('question').success).toBe(false); + }); +}); + +describe('backgroundTaskStatusSchema', () => { + it.each(['running', 'completed', 'failed', 'cancelled'] as const)( + 'accepts %s', + (s) => { + expect(backgroundTaskStatusSchema.parse(s)).toBe(s); + }, + ); + + it("rejects agent-core's 'timed_out' / 'killed' / 'lost' literals (adapter maps)", () => { + expect(backgroundTaskStatusSchema.safeParse('timed_out').success).toBe(false); + expect(backgroundTaskStatusSchema.safeParse('killed').success).toBe(false); + expect(backgroundTaskStatusSchema.safeParse('lost').success).toBe(false); + }); +}); + +describe('backgroundTaskSchema', () => { + const full: BackgroundTask = { + id: 'task_01HXYZ', + session_id: 'sess_01HZZZ', + kind: 'bash', + description: 'pnpm install', + status: 'running', + created_at: '2026-06-04T10:00:00.000Z', + started_at: '2026-06-04T10:00:00.000Z', + }; + + it('round-trips a running task', () => { + expect(backgroundTaskSchema.parse(full)).toEqual(full); + }); + + it('round-trips a completed task with completed_at + output fields', () => { + const completed: BackgroundTask = { + ...full, + status: 'completed', + completed_at: '2026-06-04T10:01:00.000Z', + output_preview: 'first line\nsecond line', + output_bytes: 4096, + }; + expect(backgroundTaskSchema.parse(completed).output_bytes).toBe(4096); + }); + + it('rejects negative output_bytes', () => { + const bad = { ...full, output_bytes: -1 }; + expect(backgroundTaskSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects malformed created_at (no timezone)', () => { + const bad = { ...full, created_at: '2026-06-04T10:00:00' }; + expect(backgroundTaskSchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/time.test.ts b/packages/protocol/src/__tests__/time.test.ts new file mode 100644 index 000000000..b813990e2 --- /dev/null +++ b/packages/protocol/src/__tests__/time.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { IsoDateTime, isoDateTimeSchema, nowIsoDateTime } from '../time'; + +describe('time — IsoDateTime', () => { + it('alias and schema are the same object', () => { + expect(IsoDateTime).toBe(isoDateTimeSchema); + }); + + it('normalizes +08:00 offset to UTC `Z`', () => { + const parsed = isoDateTimeSchema.parse('2026-06-04T18:30:00+08:00'); + expect(parsed.endsWith('Z')).toBe(true); + expect(parsed).toBe('2026-06-04T10:30:00.000Z'); + }); + + it('normalizes -05:00 offset to UTC `Z`', () => { + const parsed = isoDateTimeSchema.parse('2026-06-04T05:30:00-05:00'); + expect(parsed).toBe('2026-06-04T10:30:00.000Z'); + }); + + it('canonicalizes already-UTC input with millisecond padding', () => { + expect(isoDateTimeSchema.parse('2026-06-04T10:30:00Z')).toBe('2026-06-04T10:30:00.000Z'); + expect(isoDateTimeSchema.parse('2026-06-04T10:30:00.5Z')).toBe('2026-06-04T10:30:00.500Z'); + expect(isoDateTimeSchema.parse('2026-06-04T10:30:00.123Z')).toBe('2026-06-04T10:30:00.123Z'); + }); + + it('rejects no-offset input (offset is REQUIRED)', () => { + expect(isoDateTimeSchema.safeParse('2026-06-04T10:30:00').success).toBe(false); + }); + + it('rejects non-ISO strings', () => { + expect(isoDateTimeSchema.safeParse('not-a-date').success).toBe(false); + expect(isoDateTimeSchema.safeParse('2026/06/04 10:30:00').success).toBe(false); + expect(isoDateTimeSchema.safeParse('').success).toBe(false); + }); + + it('rejects unix epoch numbers (string form)', () => { + expect(isoDateTimeSchema.safeParse('1717497000').success).toBe(false); + }); + + it('rejects ISO-shaped but invalid dates', () => { + expect(isoDateTimeSchema.safeParse('2026-13-04T10:30:00Z').success).toBe(false); + }); + + it('nowIsoDateTime() produces a canonical-Z millisecond string', () => { + const stamp = nowIsoDateTime(); + expect(stamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(isoDateTimeSchema.parse(stamp)).toBe(stamp); + }); +}); diff --git a/packages/protocol/src/__tests__/tool.test.ts b/packages/protocol/src/__tests__/tool.test.ts new file mode 100644 index 000000000..8db5b4940 --- /dev/null +++ b/packages/protocol/src/__tests__/tool.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { + mcpServerSchema, + mcpServerStatusSchema, + mcpServerTransportSchema, + toolDescriptorSchema, + toolSourceSchema, + type McpServer, + type ToolDescriptor, +} from '../tool'; + +describe('toolSourceSchema', () => { + it.each(['builtin', 'skill', 'mcp'] as const)('accepts %s', (s) => { + expect(toolSourceSchema.parse(s)).toBe(s); + }); + + it("rejects agent-core's raw 'user' literal (adapter must map first)", () => { + expect(toolSourceSchema.safeParse('user').success).toBe(false); + }); +}); + +describe('toolDescriptorSchema', () => { + const sample: ToolDescriptor = { + name: 'Bash', + description: 'Execute a shell command', + input_schema: { type: 'object', properties: { command: { type: 'string' } } }, + source: 'builtin', + }; + + it('round-trips a builtin tool', () => { + expect(toolDescriptorSchema.parse(sample)).toEqual(sample); + }); + + it('accepts an mcp tool with mcp_server_id', () => { + const tool: ToolDescriptor = { + ...sample, + name: 'mcp:lark:search', + source: 'mcp', + mcp_server_id: 'lark', + }; + expect(toolDescriptorSchema.parse(tool).mcp_server_id).toBe('lark'); + }); + + it('allows input_schema = null (adapter emits null when surface absent)', () => { + const tool = { ...sample, input_schema: null }; + expect(toolDescriptorSchema.parse(tool).input_schema).toBeNull(); + }); + + it('rejects missing name', () => { + expect(toolDescriptorSchema.safeParse({ ...sample, name: '' }).success).toBe(false); + }); +}); + +describe('mcpServerStatusSchema', () => { + it.each(['connected', 'connecting', 'disconnected', 'error'] as const)( + 'accepts %s', + (s) => { + expect(mcpServerStatusSchema.parse(s)).toBe(s); + }, + ); + + it("rejects agent-core's 'pending' literal (adapter maps to 'connecting')", () => { + expect(mcpServerStatusSchema.safeParse('pending').success).toBe(false); + }); +}); + +describe('mcpServerTransportSchema', () => { + it.each(['stdio', 'http', 'sse'] as const)('accepts %s', (t) => { + expect(mcpServerTransportSchema.parse(t)).toBe(t); + }); +}); + +describe('mcpServerSchema', () => { + const sample: McpServer = { + id: 'lark', + name: 'lark', + transport: 'stdio', + status: 'connected', + tool_count: 7, + }; + + it('round-trips a healthy MCP server', () => { + expect(mcpServerSchema.parse(sample)).toEqual(sample); + }); + + it('round-trips an errored server with last_error', () => { + const errored: McpServer = { + ...sample, + status: 'error', + last_error: 'spawn failed: ENOENT', + }; + expect(mcpServerSchema.parse(errored).last_error).toBe('spawn failed: ENOENT'); + }); + + it('rejects negative tool_count', () => { + const bad = { ...sample, tool_count: -1 }; + expect(mcpServerSchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/ws-control.test.ts b/packages/protocol/src/__tests__/ws-control.test.ts new file mode 100644 index 000000000..8cd7ee2b1 --- /dev/null +++ b/packages/protocol/src/__tests__/ws-control.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; + +import { + abortMessageSchema, + clientControlMessageSchema, + clientHelloMessageSchema, + pingMessageSchema, + pongMessageSchema, + resyncRequiredMessageSchema, + serverHelloMessageSchema, + serverSystemMessageSchema, + subscribeMessageSchema, + unsubscribeMessageSchema, + watchFsAddMessageSchema, + watchFsRemoveMessageSchema, + wsAckEnvelopeSchema, + wsControlEnvelopeSchema, + wsErrorMessageSchema, + wsEventEnvelopeSchema, +} from '../ws-control'; +import { z } from 'zod'; + +const TS = '2026-06-04T10:30:00.000Z'; + +describe('ws-control — generic envelopes', () => { + it('wsEventEnvelopeSchema accepts a session event frame', () => { + const schema = wsEventEnvelopeSchema(z.object({ delta: z.string() })); + const parsed = schema.parse({ + type: 'event.assistant.delta', + seq: 42, + session_id: 'sess_1', + timestamp: TS, + payload: { delta: 'hi' }, + }); + expect(parsed.seq).toBe(42); + }); + + it('wsControlEnvelopeSchema accepts an id-less message', () => { + const schema = wsControlEnvelopeSchema(z.object({})); + expect(schema.safeParse({ type: 'pong', payload: {} }).success).toBe(true); + }); + + it('wsAckEnvelopeSchema requires type=ack and an id', () => { + const schema = wsAckEnvelopeSchema(z.object({})); + expect( + schema.safeParse({ type: 'ack', id: 'c1', code: 0, msg: 'success', payload: {} }).success, + ).toBe(true); + expect( + schema.safeParse({ type: 'not_ack', id: 'c1', code: 0, msg: 'success', payload: {} }).success, + ).toBe(false); + expect(schema.safeParse({ type: 'ack', code: 0, msg: 'x', payload: {} }).success).toBe(false); + }); +}); + +describe('ws-control — §3.1 server_hello', () => { + it('parses a canonical server_hello frame', () => { + const result = serverHelloMessageSchema.safeParse({ + type: 'server_hello', + timestamp: TS, + payload: { + ws_connection_id: 'conn_local', + heartbeat_ms: 30000, + max_event_buffer_size: 1000, + capabilities: { event_batching: false, compression: false }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects a server_hello missing capabilities', () => { + const result = serverHelloMessageSchema.safeParse({ + type: 'server_hello', + timestamp: TS, + payload: { + ws_connection_id: 'conn_local', + heartbeat_ms: 30000, + max_event_buffer_size: 1000, + }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ws-control — §3.2 client_hello', () => { + it('parses a canonical client_hello', () => { + const result = clientHelloMessageSchema.safeParse({ + type: 'client_hello', + id: 'c1', + payload: { + client_id: 'web_abc', + subscriptions: ['sess_1', 'sess_2'], + last_seq_by_session: { sess_1: 99 }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects a client_hello missing payload.client_id', () => { + const result = clientHelloMessageSchema.safeParse({ + type: 'client_hello', + id: 'c1', + payload: { subscriptions: [] }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ws-control — §3.3 subscribe / unsubscribe', () => { + it('subscribe accepts a watch_fs map', () => { + const result = subscribeMessageSchema.safeParse({ + type: 'subscribe', + id: 'c2', + payload: { + session_ids: ['sess_1'], + watch_fs: { + sess_1: { paths: ['src'], recursive: true }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('subscribe rejects missing session_ids', () => { + const result = subscribeMessageSchema.safeParse({ + type: 'subscribe', + id: 'c2', + payload: {}, + }); + expect(result.success).toBe(false); + }); + + it('unsubscribe parses on session_ids', () => { + const ok = unsubscribeMessageSchema.safeParse({ + type: 'unsubscribe', + id: 'c3', + payload: { session_ids: ['sess_1'] }, + }); + expect(ok.success).toBe(true); + }); + + it('unsubscribe rejects bad type literal', () => { + const bad = unsubscribeMessageSchema.safeParse({ + type: 'unsub', + id: 'c3', + payload: { session_ids: [] }, + }); + expect(bad.success).toBe(false); + }); +}); + +describe('ws-control — §3.3.1 watch_fs_add / watch_fs_remove', () => { + it('watch_fs_add accepts paths', () => { + const result = watchFsAddMessageSchema.safeParse({ + type: 'watch_fs_add', + id: 'c4', + payload: { + session_id: 'sess_1', + paths: ['src/components'], + recursive: true, + }, + }); + expect(result.success).toBe(true); + }); + + it('watch_fs_add rejects missing session_id', () => { + const result = watchFsAddMessageSchema.safeParse({ + type: 'watch_fs_add', + id: 'c4', + payload: { paths: [] }, + }); + expect(result.success).toBe(false); + }); + + it('watch_fs_remove requires session_id + paths', () => { + const ok = watchFsRemoveMessageSchema.safeParse({ + type: 'watch_fs_remove', + id: 'c5', + payload: { session_id: 'sess_1', paths: ['src/components'] }, + }); + expect(ok.success).toBe(true); + + const bad = watchFsRemoveMessageSchema.safeParse({ + type: 'watch_fs_remove', + id: 'c5', + payload: { paths: ['src/components'] }, + }); + expect(bad.success).toBe(false); + }); +}); + +describe('ws-control — §3.4 abort', () => { + it('parses a canonical abort frame', () => { + const result = abortMessageSchema.safeParse({ + type: 'abort', + id: 'c6', + payload: { session_id: 'sess_1', prompt_id: 'prompt_1' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an abort missing prompt_id', () => { + const result = abortMessageSchema.safeParse({ + type: 'abort', + id: 'c6', + payload: { session_id: 'sess_1' }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ws-control — §3.5 ping / pong', () => { + it('ping (S→C) requires timestamp + nonce', () => { + const ok = pingMessageSchema.safeParse({ + type: 'ping', + timestamp: TS, + payload: { nonce: 'n_1' }, + }); + expect(ok.success).toBe(true); + + const bad = pingMessageSchema.safeParse({ + type: 'ping', + payload: { nonce: 'n_1' }, + }); + expect(bad.success).toBe(false); + }); + + it('pong (C→S) requires nonce in payload', () => { + const ok = pongMessageSchema.safeParse({ + type: 'pong', + payload: { nonce: 'n_1' }, + }); + expect(ok.success).toBe(true); + + const bad = pongMessageSchema.safeParse({ + type: 'pong', + payload: {}, + }); + expect(bad.success).toBe(false); + }); +}); + +describe('ws-control — §3.6 resync_required', () => { + it('parses a canonical resync_required', () => { + const result = resyncRequiredMessageSchema.safeParse({ + type: 'resync_required', + timestamp: TS, + payload: { session_id: 'sess_1', reason: 'buffer_overflow', current_seq: 1234 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an unknown reason', () => { + const result = resyncRequiredMessageSchema.safeParse({ + type: 'resync_required', + timestamp: TS, + payload: { session_id: 'sess_1', reason: 'nope', current_seq: 0 }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ws-control — §3.7 error', () => { + it('parses a canonical error frame', () => { + const result = wsErrorMessageSchema.safeParse({ + type: 'error', + timestamp: TS, + payload: { code: 40001, msg: 'validation failed', fatal: false }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an error missing fatal flag', () => { + const result = wsErrorMessageSchema.safeParse({ + type: 'error', + timestamp: TS, + payload: { code: 40001, msg: 'x' }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ws-control — discriminated unions', () => { + it('clientControlMessageSchema dispatches by type', () => { + const ok = clientControlMessageSchema.safeParse({ + type: 'abort', + id: 'c7', + payload: { session_id: 'sess_1', prompt_id: 'prompt_1' }, + }); + expect(ok.success).toBe(true); + }); + + it('clientControlMessageSchema rejects an unknown control type', () => { + const result = clientControlMessageSchema.safeParse({ + type: 'launch_missiles', + id: 'c8', + payload: {}, + }); + expect(result.success).toBe(false); + }); + + it('serverSystemMessageSchema accepts server_hello / ping / resync / error', () => { + expect( + serverSystemMessageSchema.safeParse({ + type: 'ping', + timestamp: TS, + payload: { nonce: 'n_1' }, + }).success, + ).toBe(true); + expect( + serverSystemMessageSchema.safeParse({ + type: 'error', + timestamp: TS, + payload: { code: 50001, msg: 'boom', fatal: true }, + }).success, + ).toBe(true); + }); +}); diff --git a/packages/protocol/src/approval.ts b/packages/protocol/src/approval.ts new file mode 100644 index 000000000..a26fcb329 --- /dev/null +++ b/packages/protocol/src/approval.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const approvalDecisionSchema = z.enum(['approved', 'rejected', 'cancelled']); +export type ApprovalDecision = z.infer; + +export const approvalScopeSchema = z.enum(['session']); +export type ApprovalScope = z.infer; + +export const approvalRequestSchema = z.object({ + approval_id: z.string().min(1), + session_id: z.string().min(1), + turn_id: z.number().int().nonnegative().optional(), + tool_call_id: z.string().min(1), + tool_name: z.string().min(1), + action: z.string(), + tool_input_display: z.unknown(), + created_at: isoDateTimeSchema, + expires_at: isoDateTimeSchema, +}); +export type ApprovalRequest = z.infer; + +export const approvalResponseSchema = z.object({ + decision: approvalDecisionSchema, + scope: approvalScopeSchema.optional(), + feedback: z.string().optional(), + selected_label: z.string().optional(), +}); +export type ApprovalResponse = z.infer; diff --git a/packages/protocol/src/display.ts b/packages/protocol/src/display.ts new file mode 100644 index 000000000..223eef0ec --- /dev/null +++ b/packages/protocol/src/display.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; + +export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('command'), + command: z.string(), + cwd: z.string().optional(), + description: z.string().optional(), + language: z.literal('bash').optional(), + }), + z.object({ + kind: z.literal('file_io'), + operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']), + path: z.string(), + detail: z.string().optional(), + content: z.string().optional(), + before: z.string().optional(), + after: z.string().optional(), + }), + z.object({ + kind: z.literal('diff'), + path: z.string(), + before: z.string(), + after: z.string(), + hunks: z.number().optional(), + }), + z.object({ + kind: z.literal('search'), + query: z.string(), + scope: z.string().optional(), + }), + z.object({ + kind: z.literal('url_fetch'), + url: z.string(), + method: z.string().optional(), + }), + z.object({ + kind: z.literal('agent_call'), + agent_name: z.string(), + prompt: z.string(), + background: z.boolean().optional(), + }), + z.object({ + kind: z.literal('skill_call'), + skill_name: z.string(), + args: z.string().optional(), + }), + z.object({ + kind: z.literal('todo_list'), + items: z.array(z.object({ title: z.string(), status: z.string() })), + }), + z.object({ + kind: z.literal('background_task'), + task_id: z.string(), + status: z.string(), + description: z.string(), + task_kind: z.string().optional(), + }), + z.object({ + kind: z.literal('task_stop'), + task_id: z.string(), + task_description: z.string(), + }), + z.object({ + kind: z.literal('plan_review'), + plan: z.string(), + path: z.string().optional(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + }), + ) + .readonly() + .optional(), + }), + z.object({ + kind: z.literal('generic'), + summary: z.string(), + detail: z.unknown().optional(), + }), +]); + +export const ToolResultDisplaySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('command_output'), + exit_code: z.number(), + stdout: z.string().optional(), + stderr: z.string().optional(), + }), + z.object({ + kind: z.literal('file_content'), + path: z.string(), + content: z.string(), + range: z.object({ start: z.number(), end: z.number() }).optional(), + truncated: z.boolean().optional(), + }), + z.object({ + kind: z.literal('diff'), + path: z.string(), + before: z.string(), + after: z.string(), + hunks: z.number().optional(), + }), + z.object({ + kind: z.literal('search_results'), + query: z.string(), + matches: z.array(z.object({ file: z.string(), line: z.number(), text: z.string() })), + }), + z.object({ + kind: z.literal('url_content'), + url: z.string(), + status: z.number(), + preview: z.string().optional(), + content_type: z.string().optional(), + }), + z.object({ + kind: z.literal('agent_summary'), + agent_name: z.string(), + result: z.string().optional(), + steps: z.number().optional(), + }), + z.object({ + kind: z.literal('background_task'), + task_id: z.string(), + status: z.string(), + description: z.string(), + }), + z.object({ + kind: z.literal('todo_list'), + items: z.array(z.object({ title: z.string(), status: z.string() })), + }), + z.object({ kind: z.literal('structured'), data: z.unknown() }), + z.object({ + kind: z.literal('text'), + text: z.string(), + truncated: z.boolean().optional(), + }), + z.object({ + kind: z.literal('error'), + message: z.string(), + code: z.string().optional(), + }), + z.object({ + kind: z.literal('generic'), + summary: z.string(), + detail: z.unknown().optional(), + }), +]); + +export type ToolInputDisplay = z.infer; +export type ToolResultDisplay = z.infer; diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts new file mode 100644 index 000000000..892a0d768 --- /dev/null +++ b/packages/protocol/src/envelope.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const envelopeSchema = (data: T) => + z.object({ + code: z.number().int(), + msg: z.string(), + data: data.nullable(), + request_id: z.string(), + details: z.unknown().optional(), + }); + +export interface Envelope { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} + +export function okEnvelope(data: T, requestId: string): Envelope { + return { code: 0, msg: 'success', data, request_id: requestId }; +} + +export function errEnvelope(code: number, msg: string, requestId: string): Envelope { + return { code, msg, data: null, request_id: requestId }; +} diff --git a/packages/protocol/src/error-codes.ts b/packages/protocol/src/error-codes.ts new file mode 100644 index 000000000..564d887cf --- /dev/null +++ b/packages/protocol/src/error-codes.ts @@ -0,0 +1,180 @@ +/** + * Integer namespaces: + * - 0 success + * - 4xxxx 客户端错误 (HTTP-4xx analog) + * - 5xxxx daemon 内部错误 + * - 6xxxx 工具运行时 + * - 7xxxx LLM provider 透传 (msg = original upstream text) + * - 8xxxx MCP server 透传 (msg = original upstream text) + * - 9xxxx 预留 + */ + +export const ErrorCode = { + /** 成功 */ + SUCCESS: 0, + + /** Zod 校验失败,`details` 含字段路径列表 */ + VALIDATION_FAILED: 40001, + /** JSON 解析失败、字段类型错 */ + REQUEST_MALFORMED: 40002, + + /** daemon 没有任何 provider 配置 */ + AUTH_PROVISIONING_REQUIRED: 40110, + /** provider 存在但 token / api_key 缺失 */ + AUTH_TOKEN_MISSING: 40111, + /** 刷新 token 收到 401(用户撤销了授权) */ + AUTH_TOKEN_UNAUTHORIZED: 40112, + /** 默认 / 请求的 model 解析不到 provider */ + AUTH_MODEL_NOT_RESOLVED: 40113, + + /** session_id 不存在 */ + SESSION_NOT_FOUND: 40401, + /** prompt_id 不存在 */ + PROMPT_NOT_FOUND: 40402, + /** message_id 不存在 */ + MESSAGE_NOT_FOUND: 40403, + /** approval_id 不存在 */ + APPROVAL_NOT_FOUND: 40404, + /** question_id 不存在 */ + QUESTION_NOT_FOUND: 40405, + /** task_id 不存在 */ + TASK_NOT_FOUND: 40406, + /** file_id 不存在 */ + FILE_NOT_FOUND: 40407, + /** mcp_server_id 不存在 */ + MCP_SERVER_NOT_FOUND: 40408, + /** fs path 不存在 */ + FS_PATH_NOT_FOUND: 40409, + /** workspace_id 不存在 */ + WORKSPACE_NOT_FOUND: 40410, + /** fs 路径存在但当前进程无权限读取 */ + FS_PERMISSION_DENIED: 40411, + /** provider_id 不存在 */ + PROVIDER_NOT_FOUND: 40412, + /** model_id 不存在 */ + MODEL_NOT_FOUND: 40413, + + /** session 有正在进行的 prompt,拒绝新请求 */ + SESSION_BUSY: 40901, + /** approval 已被其他 client 应答 */ + APPROVAL_ALREADY_RESOLVED: 40902, + /** prompt 已结束(abort 幂等返回 0) */ + PROMPT_ALREADY_COMPLETED: 40903, + /** task 已完结,无法取消 */ + TASK_ALREADY_FINISHED: 40904, + /** mcp restart 时若已在 connecting/connected */ + MCP_ALREADY_CONNECTED: 40905, + /** fs.read 请求 file,但 path 是目录 */ + FS_IS_DIRECTORY: 40906, + /** fs.read 请求 utf-8,但 path 是二进制;client 改走 `:download` */ + FS_IS_BINARY: 40907, + /** fs.git_status 但 session.cwd 不是 git repo */ + FS_GIT_UNAVAILABLE: 40908, + /** 用户 ESC / 关闭面板放弃整组(client 调 `:dismiss`) */ + QUESTION_DISMISSED: 40909, + /** 当前历史没有可 compact 的前缀 */ + COMPACTION_UNABLE: 40910, + /** 当前历史没有足够的用户提示词可撤回 */ + SESSION_UNDO_UNAVAILABLE: 40911, + + /** approval 60s 超时 */ + APPROVAL_EXPIRED: 41001, + /** question 60s 超时 */ + QUESTION_EXPIRED: 41002, + /** 临时文件已过期 */ + FILE_EXPIRED: 41003, + + /** 上传超 50MB */ + FILE_TOO_LARGE: 41301, + /** fs.read 超 10MB */ + FS_TOO_LARGE: 41302, + /** fs.list / fs.search / fs.grep 命中超上限 */ + FS_TOO_MANY_RESULTS: 41303, + /** path 越出 session cwd 边界 */ + FS_PATH_ESCAPES_SESSION: 41304, + /** fs.grep 执行 >30s */ + FS_GREP_TIMEOUT: 41305, + + /** WS 单连接 watch_paths > 100 */ + FS_WATCH_LIMIT_EXCEEDED: 42902, + + /** 兜底 */ + INTERNAL_ERROR: 50001, + /** 写入 session 持久化失败 */ + PERSISTENCE_FAILURE: 50003, + + /** tool 执行抛错 */ + TOOL_EXECUTION_FAILED: 60001, + /** tool 在此 session 未启用 */ + TOOL_NOT_AVAILABLE: 60002, + + /** provider.* — provider 原 code 含义保留;`msg` 字段透传上游错误文本。 */ + /** mcp.* — mcp server 原 code 含义保留;`msg` 字段透传上游错误文本。 */ +} as const; + +/** + * Reserved (intentionally unallocated; do NOT reuse for new variants): + * - 40101 auth.invalid_token (daemon's own token; future) + * - 40102 auth.missing_token (daemon's own token; future) + * - 40103 auth.forbidden_origin (daemon's own token; future) + * - 42901 rate.limited + * - 50002 protocol.version_mismatch + */ + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +export const ErrorCodeReason: Readonly> = { + [ErrorCode.SUCCESS]: 'success', + + [ErrorCode.VALIDATION_FAILED]: 'validation.failed', + [ErrorCode.REQUEST_MALFORMED]: 'request.malformed', + + [ErrorCode.AUTH_PROVISIONING_REQUIRED]: 'auth.provisioning_required', + [ErrorCode.AUTH_TOKEN_MISSING]: 'auth.token_missing', + [ErrorCode.AUTH_TOKEN_UNAUTHORIZED]: 'auth.token_unauthorized', + [ErrorCode.AUTH_MODEL_NOT_RESOLVED]: 'auth.model_not_resolved', + + [ErrorCode.SESSION_NOT_FOUND]: 'session.not_found', + [ErrorCode.PROMPT_NOT_FOUND]: 'prompt.not_found', + [ErrorCode.MESSAGE_NOT_FOUND]: 'message.not_found', + [ErrorCode.APPROVAL_NOT_FOUND]: 'approval.not_found', + [ErrorCode.QUESTION_NOT_FOUND]: 'question.not_found', + [ErrorCode.TASK_NOT_FOUND]: 'task.not_found', + [ErrorCode.FILE_NOT_FOUND]: 'file.not_found', + [ErrorCode.MCP_SERVER_NOT_FOUND]: 'mcp.server_not_found', + [ErrorCode.FS_PATH_NOT_FOUND]: 'fs.path_not_found', + [ErrorCode.WORKSPACE_NOT_FOUND]: 'workspace.not_found', + [ErrorCode.FS_PERMISSION_DENIED]: 'fs.permission_denied', + [ErrorCode.PROVIDER_NOT_FOUND]: 'provider.not_found', + [ErrorCode.MODEL_NOT_FOUND]: 'model.not_found', + + [ErrorCode.SESSION_BUSY]: 'session.busy', + [ErrorCode.APPROVAL_ALREADY_RESOLVED]: 'approval.already_resolved', + [ErrorCode.PROMPT_ALREADY_COMPLETED]: 'prompt.already_completed', + [ErrorCode.TASK_ALREADY_FINISHED]: 'task.already_finished', + [ErrorCode.MCP_ALREADY_CONNECTED]: 'mcp.already_connected', + [ErrorCode.FS_IS_DIRECTORY]: 'fs.is_directory', + [ErrorCode.FS_IS_BINARY]: 'fs.is_binary', + [ErrorCode.FS_GIT_UNAVAILABLE]: 'fs.git_unavailable', + [ErrorCode.QUESTION_DISMISSED]: 'question.dismissed', + [ErrorCode.COMPACTION_UNABLE]: 'compaction.unable', + [ErrorCode.SESSION_UNDO_UNAVAILABLE]: 'session.undo_unavailable', + + [ErrorCode.APPROVAL_EXPIRED]: 'approval.expired', + [ErrorCode.QUESTION_EXPIRED]: 'question.expired', + [ErrorCode.FILE_EXPIRED]: 'file.expired', + + [ErrorCode.FILE_TOO_LARGE]: 'file.too_large', + [ErrorCode.FS_TOO_LARGE]: 'fs.too_large', + [ErrorCode.FS_TOO_MANY_RESULTS]: 'fs.too_many_results', + [ErrorCode.FS_PATH_ESCAPES_SESSION]: 'fs.path_escapes_session', + [ErrorCode.FS_GREP_TIMEOUT]: 'fs.grep_timeout', + + [ErrorCode.FS_WATCH_LIMIT_EXCEEDED]: 'fs.watch_limit_exceeded', + + [ErrorCode.INTERNAL_ERROR]: 'internal.error', + [ErrorCode.PERSISTENCE_FAILURE]: 'persistence.failure', + + [ErrorCode.TOOL_EXECUTION_FAILED]: 'tool.execution_failed', + [ErrorCode.TOOL_NOT_AVAILABLE]: 'tool.not_available', +}; diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts new file mode 100644 index 000000000..c2d703e36 --- /dev/null +++ b/packages/protocol/src/events.ts @@ -0,0 +1,572 @@ +import type { ToolInputDisplay } from './display'; + +export interface TokenUsage { + readonly inputOther: number; + readonly output: number; + readonly inputCacheRead: number; + readonly inputCacheCreation: number; +} + +export type FinishReason = + | 'completed' + | 'tool_calls' + | 'truncated' + | 'filtered' + | 'paused' + | 'other'; + +export interface UsageStatus { + readonly byModel?: Record; + readonly currentTurn?: TokenUsage; + readonly total?: TokenUsage; +} + +export type PermissionMode = 'manual' | 'yolo' | 'auto'; + +export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; + +export interface UserPromptOrigin { + readonly kind: 'user'; +} + +export interface SkillActivationOrigin { + readonly kind: 'skill_activation'; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: SkillSource; +} + +export interface InjectionOrigin { + readonly kind: 'injection'; + readonly variant: string; +} + +export interface CompactionSummaryOrigin { + readonly kind: 'compaction_summary'; +} + +export interface SystemTriggerOrigin { + readonly kind: 'system_trigger'; + readonly name: string; +} + +export type AgentCoreBackgroundTaskStatus = + | 'running' + | 'completed' + | 'failed' + | 'timed_out' + | 'killed' + | 'lost'; + +export interface BackgroundTaskOrigin { + readonly kind: 'background_task'; + readonly taskId: string; + readonly status: AgentCoreBackgroundTaskStatus; + readonly notificationId: string; +} + +export interface CronJobOrigin { + readonly kind: 'cron_job'; + readonly jobId: string; + readonly cron: string; + readonly recurring: boolean; + readonly coalescedCount: number; + readonly stale: boolean; +} + +export interface CronMissedOrigin { + readonly kind: 'cron_missed'; + readonly count: number; +} + +export interface HookResultOrigin { + readonly kind: 'hook_result'; + readonly event: string; + readonly blocked?: boolean; +} + +export interface RetryOrigin { + readonly kind: 'retry'; + readonly trigger?: string; +} + +export type PromptOrigin = + | UserPromptOrigin + | SkillActivationOrigin + | InjectionOrigin + | CompactionSummaryOrigin + | SystemTriggerOrigin + | BackgroundTaskOrigin + | CronJobOrigin + | CronMissedOrigin + | HookResultOrigin + | RetryOrigin; + +export type GoalStatus = 'active' | 'paused' | 'blocked' | 'complete'; +export type GoalActor = 'user' | 'model' | 'runtime' | 'system'; + +export interface GoalBudgetLimits { + readonly tokenBudget?: number; + readonly turnBudget?: number; + readonly wallClockBudgetMs?: number; +} + +export interface GoalBudgetReport { + readonly tokenBudget: number | null; + readonly turnBudget: number | null; + readonly wallClockBudgetMs: number | null; + readonly remainingTokens: number | null; + readonly remainingTurns: number | null; + readonly remainingWallClockMs: number | null; + readonly tokenBudgetReached: boolean; + readonly turnBudgetReached: boolean; + readonly wallClockBudgetReached: boolean; + readonly overBudget: boolean; +} + +export interface GoalSnapshot { + readonly goalId: string; + readonly objective: string; + readonly completionCriterion?: string; + readonly status: GoalStatus; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly budget: GoalBudgetReport; + readonly terminalReason?: string; +} + +export interface GoalToolResult { + readonly goal: GoalSnapshot | null; +} + +export interface GoalChangeStats { + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; +} + +export type GoalChangeKind = 'lifecycle' | 'completion'; + +export interface GoalChange { + readonly kind: GoalChangeKind; + readonly status?: GoalStatus; + readonly reason?: string; + readonly stats?: GoalChangeStats; + readonly actor?: GoalActor; +} + +export type KimiErrorCode = + | 'config.invalid' + | 'session.not_found' + | 'session.already_exists' + | 'session.id_invalid' + | 'session.id_required' + | 'session.id_empty' + | 'session.title_empty' + | 'session.state_not_found' + | 'session.state_invalid' + | 'session.fork_active_turn' + | 'session.export_not_found' + | 'session.export_missing_version' + | 'session.closed' + | 'session.permission_mode_invalid' + | 'session.thinking_empty' + | 'session.model_empty' + | 'session.plan_mode_invalid' + | 'session.approval_handler_error' + | 'session.question_handler_error' + | 'session.init_failed' + | 'agent.not_found' + | 'turn.agent_busy' + | 'goal.already_exists' + | 'goal.not_found' + | 'goal.objective_empty' + | 'goal.objective_too_long' + | 'goal.status_invalid' + | 'goal.metadata_reserved' + | 'goal.not_resumable' + | 'model.not_configured' + | 'model.config_invalid' + | 'auth.login_required' + | 'context.overflow' + | 'loop.max_steps_exceeded' + | 'provider.api_error' + | 'provider.rate_limit' + | 'provider.auth_error' + | 'provider.connection_error' + | 'skill.not_found' + | 'skill.type_unsupported' + | 'skill.name_empty' + | 'records.write_failed' + | 'compaction.failed' + | 'compaction.unable' + | 'background.task_id_empty' + | 'mcp.server_not_found' + | 'mcp.server_disabled' + | 'mcp.startup_failed' + | 'mcp.tool_name_collision' + | 'plugin.not_found' + | 'plugin.load_failed' + | 'request.invalid' + | 'request.work_dir_required' + | 'request.prompt_input_empty' + | 'shell.git_bash_not_found' + | 'not_implemented' + | 'internal'; + +export interface KimiErrorPayload { + readonly code: KimiErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Record; + readonly retryable: boolean; +} + +export interface BackgroundTaskInfoBase { + readonly taskId: string; + readonly description: string; + readonly status: AgentCoreBackgroundTaskStatus; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly timeoutMs?: number; +} + +export interface ProcessBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; +} + +export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'agent'; + readonly agentId?: string; + readonly subagentType?: string; +} + +export interface QuestionBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'question'; + readonly questionCount: number; + readonly toolCallId?: string; +} + +export type BackgroundTaskInfo = + | ProcessBackgroundTaskInfo + | AgentBackgroundTaskInfo + | QuestionBackgroundTaskInfo; + +export interface CompactionResult { + readonly summary: string; + readonly compactedCount: number; + readonly tokensBefore: number; + readonly tokensAfter: number; +} + +export interface ToolUpdate { + readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + readonly text?: string; + readonly percent?: number; + readonly customKind?: string; + readonly customData?: unknown; +} + +export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url'; + +export interface McpOAuthAuthorizationUrlUpdateData { + readonly serverName: string; + readonly authorizationUrl: string; +} + +export type TurnEndReason = 'completed' | 'cancelled' | 'failed'; + +export interface AgentStatusUpdatedEvent { + readonly type: 'agent.status.updated'; + readonly model?: string; + readonly contextTokens?: number; + readonly maxContextTokens?: number; + readonly contextUsage?: number; + readonly planMode?: boolean; + readonly swarmMode?: boolean; + readonly permission?: PermissionMode; + readonly usage?: UsageStatus; +} + +export interface SessionMetaUpdatedEvent { + readonly type: 'session.meta.updated'; + readonly title?: string; + readonly patch?: Record; +} + +export interface GoalUpdatedEvent { + readonly type: 'goal.updated'; + readonly snapshot: GoalSnapshot | null; + readonly change?: GoalChange; +} + +export interface SkillActivatedEvent { + readonly type: 'skill.activated'; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillPath?: string; + readonly skillSource?: SkillSource; +} + +export interface ErrorEvent extends KimiErrorPayload { + readonly type: 'error'; +} + +export interface WarningEvent { + readonly type: 'warning'; + readonly message: string; + readonly code?: string; +} + +export interface TurnStartedEvent { + readonly type: 'turn.started'; + readonly turnId: number; + readonly origin: PromptOrigin; +} + +export interface TurnEndedEvent { + readonly type: 'turn.ended'; + readonly turnId: number; + readonly reason: TurnEndReason; + readonly error?: KimiErrorPayload; +} + +export interface TurnStepStartedEvent { + readonly type: 'turn.step.started'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; +} + +export interface TurnStepCompletedEvent { + readonly type: 'turn.step.completed'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly usage?: TokenUsage; + readonly finishReason?: string; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + readonly providerFinishReason?: FinishReason; + readonly rawFinishReason?: string; +} + +export interface TurnStepRetryingEvent { + readonly type: 'turn.step.retrying'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export interface TurnStepInterruptedEvent { + readonly type: 'turn.step.interrupted'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly reason: string; + readonly message?: string; +} + +export interface AssistantDeltaEvent { + readonly type: 'assistant.delta'; + readonly turnId: number; + readonly delta: string; +} + +export interface HookResultEvent { + readonly type: 'hook.result'; + readonly turnId: number; + readonly hookEvent: string; + readonly content: string; + readonly blocked?: boolean; +} + +export interface ThinkingDeltaEvent { + readonly type: 'thinking.delta'; + readonly turnId: number; + readonly delta: string; +} + +export interface ToolCallDeltaEvent { + readonly type: 'tool.call.delta'; + readonly turnId: number; + readonly toolCallId: string; + readonly name?: string; + readonly argumentsPart?: string; +} + +export interface ToolCallStartedEvent { + readonly type: 'tool.call.started'; + readonly turnId: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly description?: string; + readonly display?: ToolInputDisplay; +} + +export interface ToolProgressEvent { + readonly type: 'tool.progress'; + readonly turnId: number; + readonly toolCallId: string; + readonly update: ToolUpdate; +} + +export interface ToolResultEvent { + readonly type: 'tool.result'; + readonly turnId: number; + readonly toolCallId: string; + readonly output: unknown; + readonly isError?: boolean; + readonly synthetic?: boolean; +} + +export interface SubagentSpawnedEvent { + readonly type: 'subagent.spawned'; + readonly subagentId: string; + readonly subagentName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly parentAgentId?: string; + readonly description?: string; + readonly swarmIndex?: number; + readonly runInBackground: boolean; +} + +export interface SubagentStartedEvent { + readonly type: 'subagent.started'; + readonly subagentId: string; +} + +export interface SubagentSuspendedEvent { + readonly type: 'subagent.suspended'; + readonly subagentId: string; + readonly reason: string; +} + +export interface SubagentCompletedEvent { + readonly type: 'subagent.completed'; + readonly subagentId: string; + readonly resultSummary: string; + readonly usage?: TokenUsage; + readonly contextTokens?: number; +} + +export interface SubagentFailedEvent { + readonly type: 'subagent.failed'; + readonly subagentId: string; + readonly error: string; +} + +export interface CompactionStartedEvent { + readonly type: 'compaction.started'; + readonly trigger: 'manual' | 'auto'; + readonly instruction?: string; +} + +export interface CompactionBlockedEvent { + readonly type: 'compaction.blocked'; + readonly turnId?: number; +} + +export interface CompactionCancelledEvent { + readonly type: 'compaction.cancelled'; +} + +export interface CompactionCompletedEvent { + readonly type: 'compaction.completed'; + readonly result: CompactionResult; +} + +export interface BackgroundTaskStartedEvent { + readonly type: 'background.task.started'; + readonly info: BackgroundTaskInfo; +} + +export interface BackgroundTaskTerminatedEvent { + readonly type: 'background.task.terminated'; + readonly info: BackgroundTaskInfo; +} + +export interface CronFiredEvent { + readonly type: 'cron.fired'; + readonly origin: CronJobOrigin; + readonly prompt: string; +} + +export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; + +export interface ToolListUpdatedEvent { + readonly type: 'tool.list.updated'; + readonly reason: ToolListUpdatedReason; + readonly serverName: string; +} + +export interface McpServerStatusEvent { + readonly type: 'mcp.server.status'; + readonly server: McpServerStatusPayload; +} + +export interface McpServerStatusPayload { + readonly name: string; + readonly transport: 'stdio' | 'http'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; + readonly toolCount: number; + readonly error?: string; +} + +export type AgentEvent = + | ErrorEvent + | WarningEvent + | AgentStatusUpdatedEvent + | SessionMetaUpdatedEvent + | GoalUpdatedEvent + | SkillActivatedEvent + | TurnStartedEvent + | TurnEndedEvent + | TurnStepStartedEvent + | TurnStepCompletedEvent + | TurnStepRetryingEvent + | TurnStepInterruptedEvent + | AssistantDeltaEvent + | HookResultEvent + | ThinkingDeltaEvent + | ToolCallDeltaEvent + | ToolCallStartedEvent + | ToolProgressEvent + | ToolResultEvent + | ToolListUpdatedEvent + | McpServerStatusEvent + | SubagentSpawnedEvent + | SubagentStartedEvent + | SubagentSuspendedEvent + | SubagentCompletedEvent + | SubagentFailedEvent + | CompactionStartedEvent + | CompactionBlockedEvent + | CompactionCancelledEvent + | CompactionCompletedEvent + | BackgroundTaskStartedEvent + | BackgroundTaskTerminatedEvent + | CronFiredEvent; + +export type Event = AgentEvent & { agentId: string; sessionId: string }; diff --git a/packages/protocol/src/file.ts b/packages/protocol/src/file.ts new file mode 100644 index 000000000..330aba066 --- /dev/null +++ b/packages/protocol/src/file.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const fileMetaSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + media_type: z.string().min(1), + size: z.number().int().nonnegative(), + created_at: isoDateTimeSchema, + expires_at: isoDateTimeSchema.optional(), +}); +export type FileMeta = z.infer; diff --git a/packages/protocol/src/fs.ts b/packages/protocol/src/fs.ts new file mode 100644 index 000000000..39e2c9a1b --- /dev/null +++ b/packages/protocol/src/fs.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const fsKindSchema = z.enum(['file', 'directory', 'symlink']); +export type FsKind = z.infer; + +export const fsGitStatusSchema = z.enum([ + 'clean', + 'modified', + 'added', + 'deleted', + 'renamed', + 'untracked', + 'ignored', + 'conflicted', +]); +export type FsGitStatus = z.infer; + +export const fsEntrySchema = z.object({ + path: z.string(), + name: z.string(), + kind: fsKindSchema, + size: z.number().int().nonnegative().optional(), + modified_at: isoDateTimeSchema, + etag: z.string().optional(), + mime: z.string().optional(), + language_id: z.string().optional(), + is_binary: z.boolean().optional(), + is_symlink_to: z.string().optional(), + git_status: fsGitStatusSchema.optional(), + child_count: z.number().int().nonnegative().optional(), +}); +export type FsEntry = z.infer; + +export const fsSearchHitSchema = z.object({ + path: z.string(), + name: z.string(), + kind: fsKindSchema, + score: z.number().min(0).max(1), + match_positions: z.array(z.number().int().nonnegative()), +}); +export type FsSearchHit = z.infer; + +export const fsGrepMatchSchema = z.object({ + line: z.number().int().positive(), + col: z.number().int().positive(), + text: z.string(), + before: z.array(z.string()), + after: z.array(z.string()), +}); +export type FsGrepMatch = z.infer; + +export const fsGrepFileHitSchema = z.object({ + path: z.string(), + matches: z.array(fsGrepMatchSchema), +}); +export type FsGrepFileHit = z.infer; + +export const fsGitStatusEntrySchema = z.object({ + path: z.string(), + status: fsGitStatusSchema, + rename_from: z.string().optional(), +}); +export type FsGitStatusEntry = z.infer; + +export const fsChangeKindSchema = z.enum(['file', 'directory', 'symlink']); +export type FsChangeKind = z.infer; + +export const fsChangeActionSchema = z.enum(['created', 'modified', 'deleted']); +export type FsChangeAction = z.infer; + +export const fsChangeEntrySchema = z.object({ + path: z.string(), + change: fsChangeActionSchema, + kind: fsChangeKindSchema, + size_delta: z.number().int().optional(), + etag: z.string().optional(), +}); +export type FsChangeEntry = z.infer; + +export const fsChangeEventSchema = z.object({ + changes: z.array(fsChangeEntrySchema), + coalesced_window_ms: z.number().int().positive(), + truncated: z.boolean().optional(), + count: z.number().int().nonnegative().optional(), +}); +export type FsChangeEvent = z.infer; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 000000000..3a63d49e4 --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,35 @@ +export * from './envelope'; +export * from './error-codes'; +export * from './pagination'; +export * from './time'; +export * from './request-id'; +export * from './events'; +export * from './display'; +export * from './ws-control'; + +export * from './session'; +export * from './workspace'; +export * from './message'; +export * from './approval'; +export * from './question'; +export * from './tool'; +export * from './task'; +export * from './fs'; +export * from './file'; +export * from './modelCatalog'; + +export * from './rest/meta'; +export * from './rest/auth'; +export * from './rest/oauth'; +export * from './rest/session'; +export * from './rest/workspace'; +export * from './rest/fsBrowse'; +export * from './rest/message'; +export * from './rest/prompt'; +export * from './rest/approval'; +export * from './rest/question'; +export * from './rest/tool'; +export * from './rest/task'; +export * from './rest/fs'; +export * from './rest/file'; +export * from './rest/modelCatalog'; diff --git a/packages/protocol/src/message.ts b/packages/protocol/src/message.ts new file mode 100644 index 000000000..e60d63059 --- /dev/null +++ b/packages/protocol/src/message.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const messageRoleSchema = z.enum(['user', 'assistant', 'tool', 'system']); +export type MessageRole = z.infer; + +export const textContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); +export type TextContent = z.infer; + +export const toolUseContentSchema = z.object({ + type: z.literal('tool_use'), + tool_call_id: z.string().min(1), + tool_name: z.string().min(1), + input: z.unknown(), +}); +export type ToolUseContent = z.infer; + +export const toolResultContentSchema = z.object({ + type: z.literal('tool_result'), + tool_call_id: z.string().min(1), + output: z.unknown(), + is_error: z.boolean().optional(), +}); +export type ToolResultContent = z.infer; + +export const imageSourceSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('url'), url: z.string().min(1) }), + z.object({ + kind: z.literal('base64'), + media_type: z.string().min(1), + data: z.string().min(1), + }), + z.object({ kind: z.literal('file'), file_id: z.string().min(1) }), +]); +export type ImageSource = z.infer; + +export const imageContentSchema = z.object({ + type: z.literal('image'), + source: imageSourceSchema, +}); +export type ImageContent = z.infer; + +export const fileContentSchema = z.object({ + type: z.literal('file'), + file_id: z.string().min(1), + name: z.string(), + media_type: z.string().min(1), + size: z.number().int().nonnegative(), +}); +export type FileContent = z.infer; + +export const thinkingContentSchema = z.object({ + type: z.literal('thinking'), + thinking: z.string(), + signature: z.string().optional(), +}); +export type ThinkingContent = z.infer; + +export const messageContentSchema = z.discriminatedUnion('type', [ + textContentSchema, + toolUseContentSchema, + toolResultContentSchema, + imageContentSchema, + fileContentSchema, + thinkingContentSchema, +]); +export type MessageContent = z.infer; + +export const messageSchema = z.object({ + id: z.string().min(1), + session_id: z.string().min(1), + role: messageRoleSchema, + content: z.array(messageContentSchema), + created_at: isoDateTimeSchema, + prompt_id: z.string().min(1).optional(), + parent_message_id: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type Message = z.infer; diff --git a/packages/protocol/src/modelCatalog.ts b/packages/protocol/src/modelCatalog.ts new file mode 100644 index 000000000..8bc7e9be6 --- /dev/null +++ b/packages/protocol/src/modelCatalog.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const modelCatalogItemSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + display_name: z.string().min(1).optional(), + max_context_size: z.number().int().min(1), + capabilities: z.array(z.string()).optional(), +}); +export type ModelCatalogItem = z.infer; + +export const providerCatalogStatusSchema = z.enum([ + 'connected', + 'error', + 'unconfigured', +]); +export type ProviderCatalogStatus = z.infer; + +export const providerCatalogItemSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + base_url: z.string().min(1).optional(), + default_model: z.string().min(1).optional(), + has_api_key: z.boolean(), + status: providerCatalogStatusSchema, + models: z.array(z.string().min(1)).optional(), +}); +export type ProviderCatalogItem = z.infer; diff --git a/packages/protocol/src/pagination.ts b/packages/protocol/src/pagination.ts new file mode 100644 index 000000000..f8910bce5 --- /dev/null +++ b/packages/protocol/src/pagination.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { ErrorCode } from './error-codes'; + +export const cursorQuerySchema = z + .object({ + before_id: z.string().min(1).optional(), + after_id: z.string().min(1).optional(), + page_size: z.number().int().min(1).max(100).optional(), + }) + .superRefine((value, ctx) => { + if (value.before_id !== undefined && value.after_id !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'before_id and after_id are mutually exclusive', + path: ['before_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +export type CursorQuery = z.infer; + +export const CursorQuery = cursorQuerySchema; + +export const pageResponseSchema = (item: T) => + z.object({ + items: z.array(item), + has_more: z.boolean(), + }); + +export interface PageResponse { + items: T[]; + has_more: boolean; +} diff --git a/packages/protocol/src/question.ts b/packages/protocol/src/question.ts new file mode 100644 index 000000000..3bfdf8c9f --- /dev/null +++ b/packages/protocol/src/question.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const questionOptionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), +}); +export type QuestionOption = z.infer; + +export const questionItemSchema = z.object({ + id: z.string().min(1), + question: z.string().min(1), + header: z.string().optional(), + body: z.string().optional(), + options: z.array(questionOptionSchema).min(2).max(4), + multi_select: z.boolean().optional(), + allow_other: z.boolean().optional(), + other_label: z.string().optional(), + other_description: z.string().optional(), +}); +export type QuestionItem = z.infer; + +export const questionRequestSchema = z.object({ + question_id: z.string().min(1), + session_id: z.string().min(1), + turn_id: z.number().int().nonnegative().optional(), + tool_call_id: z.string().min(1).optional(), + questions: z.array(questionItemSchema).min(1).max(4), + created_at: isoDateTimeSchema, + expires_at: isoDateTimeSchema, +}); +export type QuestionRequest = z.infer; + +export const questionAnswerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('single'), option_id: z.string().min(1) }), + z.object({ kind: z.literal('multi'), option_ids: z.array(z.string().min(1)).min(1) }), + z.object({ kind: z.literal('other'), text: z.string() }), + z.object({ + kind: z.literal('multi_with_other'), + option_ids: z.array(z.string().min(1)), + other_text: z.string(), + }), + z.object({ kind: z.literal('skipped') }), +]); +export type QuestionAnswer = z.infer; + +export const questionAnswerMethodSchema = z.enum(['enter', 'space', 'number_key', 'click']); +export type QuestionAnswerMethod = z.infer; + +export const questionResponseSchema = z.object({ + answers: z.record(z.string().min(1), questionAnswerSchema), + method: questionAnswerMethodSchema.optional(), + note: z.string().optional(), +}); +export type QuestionResponse = z.infer; diff --git a/packages/protocol/src/request-id.ts b/packages/protocol/src/request-id.ts new file mode 100644 index 000000000..2ed5c9df2 --- /dev/null +++ b/packages/protocol/src/request-id.ts @@ -0,0 +1,14 @@ +import { isValid, ulid } from 'ulid'; + +export const ulidRegex = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/; + +export function parseOrGenerateRequestId(headerValue: string | undefined): string { + if (typeof headerValue === 'string' && isValid(headerValue)) { + return headerValue; + } + return ulid(); +} + +export function isUlid(value: string): boolean { + return isValid(value); +} diff --git a/packages/protocol/src/rest/approval.ts b/packages/protocol/src/rest/approval.ts new file mode 100644 index 000000000..c247f5a64 --- /dev/null +++ b/packages/protocol/src/rest/approval.ts @@ -0,0 +1,43 @@ +/** + * GET /v1/sessions/{session_id}/approvals?status=pending + * Reply: { items: ApprovalRequest[] } + * + * POST /v1/sessions/{session_id}/approvals/{approval_id} + * Body: ApprovalResponse (decision + optional scope/feedback/selected_label) + * Reply: ApprovalResolveResult { resolved: true, resolved_at } + * + * **Error codes** (REST.md §3.6): + * - 40001 (validation.failed) + * - 40404 (approval.not_found) + * - 40902 (approval.already_resolved) — custom envelope w/ data:{resolved:false} + * - 41001 (approval.expired) + */ + +import { z } from 'zod'; + +import { approvalRequestSchema, approvalResponseSchema } from '../approval'; +import { isoDateTimeSchema } from '../time'; + +export const listPendingApprovalsQuerySchema = z.object({ + status: z.literal('pending'), +}); +export type ListPendingApprovalsQuery = z.infer; + +export const listPendingApprovalsResponseSchema = z.object({ + items: z.array(approvalRequestSchema), +}); +export type ListPendingApprovalsResponse = z.infer; + +export const approvalResolveRequestSchema = approvalResponseSchema; +export type ApprovalResolveRequest = z.infer; + +export const approvalResolveResultSchema = z.object({ + resolved: z.literal(true), + resolved_at: isoDateTimeSchema, +}); +export type ApprovalResolveResult = z.infer; + +export const approvalAlreadyResolvedDataSchema = z.object({ + resolved: z.literal(false), +}); +export type ApprovalAlreadyResolvedData = z.infer; diff --git a/packages/protocol/src/rest/auth.ts b/packages/protocol/src/rest/auth.ts new file mode 100644 index 000000000..8edb08884 --- /dev/null +++ b/packages/protocol/src/rest/auth.ts @@ -0,0 +1,32 @@ +/** + * GET /v1/auth + * Reply: AuthSummary { + * ready, + * providers_count, + * default_model, + * managed_provider + * } + */ +import { z } from 'zod'; + +export const managedProviderStatusSchema = z.enum([ + 'authenticated', + 'expired', + 'revoked', + 'unauthenticated', +]); +export type ManagedProviderStatus = z.infer; + +export const managedProviderSummarySchema = z.object({ + name: z.string().min(1), + status: managedProviderStatusSchema, +}); +export type ManagedProviderSummary = z.infer; + +export const authSummarySchema = z.object({ + ready: z.boolean(), + providers_count: z.number().int().nonnegative(), + default_model: z.string().nullable(), + managed_provider: managedProviderSummarySchema.nullable(), +}); +export type AuthSummary = z.infer; diff --git a/packages/protocol/src/rest/file.ts b/packages/protocol/src/rest/file.ts new file mode 100644 index 000000000..8df36cf41 --- /dev/null +++ b/packages/protocol/src/rest/file.ts @@ -0,0 +1,36 @@ +/** + * POST /v1/files + * Request: multipart/form-data with `file` (binary), `name` + * (optional override), `expires_in_sec` (optional). + * Response data: `FileMeta` (full envelope). + * Errors: 41301 (>50MB). + * + * GET /v1/files/{file_id} + * Response: binary stream or envelope (40407 / 41003). + * + * DELETE /v1/files/{file_id} + * Response data: `{deleted: true}` (envelope-wrapped). + * Errors: 40407. + */ + +import { z } from 'zod'; + +import { fileMetaSchema } from '../file'; + +export const uploadFileResponseSchema = fileMetaSchema; +export type UploadFileResponse = z.infer; + +export const getFileParamSchema = z.object({ + file_id: z.string().min(1), +}); +export type GetFileParam = z.infer; + +export const deleteFileParamSchema = z.object({ + file_id: z.string().min(1), +}); +export type DeleteFileParam = z.infer; + +export const deleteFileResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteFileResponse = z.infer; diff --git a/packages/protocol/src/rest/fs.ts b/packages/protocol/src/rest/fs.ts new file mode 100644 index 000000000..28d395bc8 --- /dev/null +++ b/packages/protocol/src/rest/fs.ts @@ -0,0 +1,207 @@ +/** + * POST /v1/sessions/{sid}/fs:list + * Body: FsListRequest + * Response data: FsListResponse { items, children_by_path?, truncated } + * Errors: 40401, 40409, 41304, 41303 + * + * POST /v1/sessions/{sid}/fs:read + * Body: FsReadRequest + * Response data: FsReadResponse + * Errors: 40401, 40409, 40906, 40907, 41302, 41304 + * POST /v1/sessions/{sid}/fs:list_many + * Body: FsListManyRequest (paths[] up to 100) + * Response data: FsListManyResponse { results, truncated_paths?, + * partial_errors? } + * + * POST /v1/sessions/{sid}/fs:stat + * Body: FsStatRequest + * Response data: FsEntry + * Errors: 40401, 40409, 41304 + * + * POST /v1/sessions/{sid}/fs:stat_many + * Body: FsStatManyRequest (paths[] up to 1000) + * Response data: FsStatManyResponse { entries: { [path]: FsEntry | null } } + * Per-path failures land as `null` (REST.md §3.9 line 524); only + * path-safety (41304) fails the whole call. + * POST /v1/sessions/{sid}/fs:search + * Body: FsSearchRequest { query, limit?, include_globs?, exclude_globs?, + * follow_gitignore? } + * Response data: FsSearchResponse { items, truncated } + * Errors: 40401, 41303, 41304 + * + * POST /v1/sessions/{sid}/fs:grep + * Body: FsGrepRequest + * Response data: FsGrepResponse { files, files_scanned, truncated, + * elapsed_ms } + * Errors: 40401, 41303, 41304, 41305 (>30s grep timeout) + * POST /v1/sessions/{sid}/fs:git_status + * Body: FsGitStatusRequest { paths? } + * Response data: FsGitStatusResponse { branch, ahead, behind, entries } + * Errors: 40401, 40908 (not a git repo), 41304 + * + * GET /v1/sessions/{sid}/fs/{path}:download + * Response: binary stream or envelope (40401 / 40409 / 41304) + */ + +import { z } from 'zod'; + +import { + fsEntrySchema, + fsGitStatusSchema, + fsGrepFileHitSchema, + fsSearchHitSchema, +} from '../fs'; + +export const fsListSortSchema = z.enum([ + 'type_first', + 'name_asc', + 'name_desc', + 'mtime_desc', + 'size_desc', +]); +export type FsListSort = z.infer; + +export const fsListRequestSchema = z.object({ + path: z.string().default('.'), + depth: z.number().int().min(1).max(10).default(1), + limit: z.number().int().min(1).max(1000).default(200), + show_hidden: z.boolean().default(false), + follow_gitignore: z.boolean().default(true), + exclude_globs: z.array(z.string()).optional(), + sort: fsListSortSchema.default('type_first'), + include_git_status: z.boolean().default(false), +}); +export type FsListRequest = z.infer; + +export const fsListResponseSchema = z.object({ + items: z.array(fsEntrySchema), + children_by_path: z.record(z.string(), z.array(fsEntrySchema)).optional(), + truncated: z.boolean(), +}); +export type FsListResponse = z.infer; + +export const fsReadEncodingRequestSchema = z.enum(['auto', 'utf-8', 'base64']); +export const fsReadEncodingResponseSchema = z.enum(['utf-8', 'base64']); +export type FsReadEncoding = z.infer; + +export const fsReadRequestSchema = z.object({ + path: z.string().min(1), + offset: z.number().int().nonnegative().default(0), + length: z.number().int().min(1).max(10_485_760).default(1_048_576), + encoding: fsReadEncodingRequestSchema.default('auto'), +}); +export type FsReadRequest = z.infer; + +export const fsReadResponseSchema = z.object({ + path: z.string(), + content: z.string(), + encoding: fsReadEncodingResponseSchema, + size: z.number().int().nonnegative(), + truncated: z.boolean(), + etag: z.string(), + mime: z.string(), + language_id: z.string().optional(), + line_count: z.number().int().nonnegative().optional(), + is_binary: z.boolean(), +}); +export type FsReadResponse = z.infer; + +export const fsListManyRequestSchema = z.object({ + paths: z.array(z.string().min(1)).min(1).max(100), + depth: z.number().int().min(1).max(10).default(1), + limit: z.number().int().min(1).max(1000).default(200), + show_hidden: z.boolean().default(false), + follow_gitignore: z.boolean().default(true), + exclude_globs: z.array(z.string()).optional(), + sort: fsListSortSchema.default('type_first'), + include_git_status: z.boolean().default(false), +}); +export type FsListManyRequest = z.infer; + +export const fsListManyPartialErrorSchema = z.object({ + code: z.number().int(), + msg: z.string(), +}); +export type FsListManyPartialError = z.infer; + +export const fsListManyResponseSchema = z.object({ + results: z.record(z.string(), z.array(fsEntrySchema)), + truncated_paths: z.array(z.string()).optional(), + partial_errors: z.record(z.string(), fsListManyPartialErrorSchema).optional(), +}); +export type FsListManyResponse = z.infer; + +export const fsStatRequestSchema = z.object({ + path: z.string().min(1), +}); +export type FsStatRequest = z.infer; + +export const fsStatResponseSchema = fsEntrySchema; +export type FsStatResponse = z.infer; + +export const fsStatManyRequestSchema = z.object({ + paths: z.array(z.string().min(1)).min(1).max(1000), +}); +export type FsStatManyRequest = z.infer; + +export const fsStatManyResponseSchema = z.object({ + entries: z.record(z.string(), fsEntrySchema.nullable()), +}); +export type FsStatManyResponse = z.infer; + +export const fsSearchRequestSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().min(1).max(200).default(50), + include_globs: z.array(z.string()).optional(), + exclude_globs: z.array(z.string()).optional(), + follow_gitignore: z.boolean().default(true), +}); +export type FsSearchRequest = z.infer; + +export const fsSearchResponseSchema = z.object({ + items: z.array(fsSearchHitSchema), + truncated: z.boolean(), +}); +export type FsSearchResponse = z.infer; + +export const fsGrepRequestSchema = z.object({ + pattern: z.string().min(1), + regex: z.boolean().default(false), + case_sensitive: z.boolean().default(true), + include_globs: z.array(z.string()).optional(), + exclude_globs: z.array(z.string()).optional(), + follow_gitignore: z.boolean().default(true), + max_files: z.number().int().min(1).max(10_000).default(200), + max_matches_per_file: z.number().int().min(1).max(10_000).default(50), + max_total_matches: z.number().int().min(1).max(100_000).default(5000), + context_lines: z.number().int().min(0).max(10).default(2), +}); +export type FsGrepRequest = z.infer; + +export const fsGrepResponseSchema = z.object({ + files: z.array(fsGrepFileHitSchema), + files_scanned: z.number().int().nonnegative(), + truncated: z.boolean(), + elapsed_ms: z.number().int().nonnegative(), +}); +export type FsGrepResponse = z.infer; + +export const fsGitStatusRequestSchema = z.object({ + paths: z.array(z.string().min(1)).optional(), +}); +export type FsGitStatusRequest = z.infer; + +export const fsGitStatusResponseSchema = z.object({ + branch: z.string(), + ahead: z.number().int().nonnegative(), + behind: z.number().int().nonnegative(), + entries: z.record(z.string(), fsGitStatusSchema), +}); +export type FsGitStatusResponse = z.infer; + +export const fsDownloadParamsSchema = z.object({ + path: z.string().min(1), + range: z.string().optional(), + if_none_match: z.string().optional(), +}); +export type FsDownloadParams = z.infer; diff --git a/packages/protocol/src/rest/fsBrowse.ts b/packages/protocol/src/rest/fsBrowse.ts new file mode 100644 index 000000000..adae5ebfd --- /dev/null +++ b/packages/protocol/src/rest/fsBrowse.ts @@ -0,0 +1,41 @@ +/** + * GET /v1/fs:browse?path= + * Reply: FsBrowseResponse + * + * GET /v1/fs:home + * Reply: FsHomeResponse + * + * Errors: + * - 40001 validation.failed (path is not absolute) + * - 40409 fs.path_not_found (ENOENT / ENOTDIR) + * - 40411 fs.permission_denied (EACCES) + */ + +import { z } from 'zod'; + +export const fsBrowseQuerySchema = z.object({ + path: z.string().min(1).optional(), +}); +export type FsBrowseQuery = z.infer; + +export const fsBrowseEntrySchema = z.object({ + name: z.string().min(1), + path: z.string().min(1), + is_dir: z.literal(true), + is_git_repo: z.boolean(), + branch: z.string().optional(), +}); +export type FsBrowseEntry = z.infer; + +export const fsBrowseResponseSchema = z.object({ + path: z.string().min(1), + parent: z.string().min(1).nullable(), + entries: z.array(fsBrowseEntrySchema), +}); +export type FsBrowseResponse = z.infer; + +export const fsHomeResponseSchema = z.object({ + home: z.string().min(1), + recent_roots: z.array(z.string().min(1)), +}); +export type FsHomeResponse = z.infer; diff --git a/packages/protocol/src/rest/message.ts b/packages/protocol/src/rest/message.ts new file mode 100644 index 000000000..bba87a314 --- /dev/null +++ b/packages/protocol/src/rest/message.ts @@ -0,0 +1,31 @@ +/** + * GET /v1/sessions/{session_id}/messages + * Query: cursorQuery + role + * Default page_size=50; max 100 (per SCHEMAS §1.3). + * Response data: Page + * + * GET /v1/sessions/{session_id}/messages/{message_id} + * Response data: Message + * Errors: 40401 (session.not_found) + 40403 (message.not_found) + */ + +import { z } from 'zod'; + +import { messageRoleSchema, messageSchema } from '../message'; +import { cursorQuerySchema } from '../pagination'; + +export const listMessagesQuerySchema = cursorQuerySchema.and( + z.object({ + role: messageRoleSchema.optional(), + }), +); +export type ListMessagesQuery = z.infer; + +export const listMessagesResponseSchema = z.object({ + items: z.array(messageSchema), + has_more: z.boolean(), +}); +export type ListMessagesResponse = z.infer; + +export const getMessageResponseSchema = messageSchema; +export type GetMessageResponse = z.infer; diff --git a/packages/protocol/src/rest/meta.ts b/packages/protocol/src/rest/meta.ts new file mode 100644 index 000000000..2ecf47167 --- /dev/null +++ b/packages/protocol/src/rest/meta.ts @@ -0,0 +1,31 @@ +/** + * GET /v1/meta + * Reply: MetaResponse { + * daemon_version, + * capabilities, + * daemon_id, + * started_at + * } + */ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '../time'; + +export const metaCapabilitiesSchema = z.object({ + websocket: z.literal(true), + file_upload: z.literal(true), + fs_query: z.literal(true), + mcp: z.literal(true), + background_tasks: z.literal(true), +}); + +export type MetaCapabilities = z.infer; + +export const metaResponseSchema = z.object({ + daemon_version: z.string().min(1), + capabilities: metaCapabilitiesSchema, + daemon_id: z.string().min(1), + started_at: isoDateTimeSchema, +}); + +export type MetaResponse = z.infer; diff --git a/packages/protocol/src/rest/modelCatalog.ts b/packages/protocol/src/rest/modelCatalog.ts new file mode 100644 index 000000000..ba20d29cb --- /dev/null +++ b/packages/protocol/src/rest/modelCatalog.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { + modelCatalogItemSchema, + providerCatalogItemSchema, +} from '../modelCatalog'; + +export const listModelsResponseSchema = z.object({ + items: z.array(modelCatalogItemSchema), +}); +export type ListModelsResponse = z.infer; + +export const listProvidersResponseSchema = z.object({ + items: z.array(providerCatalogItemSchema), +}); +export type ListProvidersResponse = z.infer; + +export const getProviderResponseSchema = providerCatalogItemSchema; +export type GetProviderResponse = z.infer; + +export const setDefaultModelResponseSchema = z.object({ + default_model: z.string().min(1), + model: modelCatalogItemSchema, +}); +export type SetDefaultModelResponse = z.infer; diff --git a/packages/protocol/src/rest/oauth.ts b/packages/protocol/src/rest/oauth.ts new file mode 100644 index 000000000..0703db410 --- /dev/null +++ b/packages/protocol/src/rest/oauth.ts @@ -0,0 +1,73 @@ +/** + * POST /v1/oauth/login body: { provider? } data: OAuthFlowStart + * GET /v1/oauth/login query: { provider? } data: OAuthFlowStatus | null + * DELETE /v1/oauth/login query: { provider? } data: { cancelled, status } + * POST /v1/oauth/logout body: { provider? } data: { logged_out, provider } + */ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '../time'; + +export const oauthFlowStatusEnum = z.enum([ + 'pending', + 'authenticated', + 'denied', + 'expired', + 'cancelled', +]); +export type OAuthFlowStatus = z.infer; + +export const oauthLoginStartRequestSchema = z.object({ + provider: z.string().min(1).optional(), +}); +export type OAuthLoginStartRequest = z.infer; + +export const oauthFlowStartSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + user_code: z.string().min(1), + expires_in: z.number().int().positive(), + interval: z.number().int().positive(), + status: z.literal('pending'), + expires_at: isoDateTimeSchema, +}); +export type OAuthFlowStart = z.infer; + +export const oauthFlowSnapshotSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + status: oauthFlowStatusEnum, + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + user_code: z.string().min(1), + expires_in: z.number().int().positive(), + expires_at: isoDateTimeSchema, + interval: z.number().int().positive(), + resolved_at: isoDateTimeSchema.optional(), + error_message: z.string().optional(), +}); +export type OAuthFlowSnapshot = z.infer; + +export const oauthLoginQuerySchema = z.object({ + provider: z.string().min(1).optional(), +}); +export type OAuthLoginQuery = z.infer; + +export const oauthLoginCancelResponseSchema = z.object({ + cancelled: z.boolean(), + status: oauthFlowStatusEnum, +}); +export type OAuthLoginCancelResponse = z.infer; + +export const oauthLogoutRequestSchema = z.object({ + provider: z.string().min(1).optional(), +}); +export type OAuthLogoutRequest = z.infer; + +export const oauthLogoutResponseSchema = z.object({ + logged_out: z.literal(true), + provider: z.string().min(1), +}); +export type OAuthLogoutResponse = z.infer; diff --git a/packages/protocol/src/rest/prompt.ts b/packages/protocol/src/rest/prompt.ts new file mode 100644 index 000000000..173479eca --- /dev/null +++ b/packages/protocol/src/rest/prompt.ts @@ -0,0 +1,119 @@ +/** + * POST /v1/sessions/{sid}/prompts + * Body: PromptSubmission { + * content: MessageContent[], + * metadata?: ..., + * model?: string, + * thinking?: 'off'|'low'|'medium'|'high'|'xhigh'|'max', + * permission_mode?: 'manual'|'yolo'|'auto', + * plan_mode?: boolean, + * } + * Reply: PromptSubmitResult { prompt_id, user_message_id, status, content, created_at } + * status='running' when sent immediately, status='queued' when + * another prompt is already active. + * + * GET /v1/sessions/{sid}/prompts + * Reply: { active: PromptItem | null, queued: PromptItem[] } + * + * POST /v1/sessions/{sid}/prompts/{pid}:steer + * POST /v1/sessions/{sid}/prompts:steer + * Body: { prompt_ids: string[] } for the collection route + * Reply: { steered: true, prompt_ids: string[] } + * + * POST /v1/sessions/{sid}/prompts/{pid}:abort + * Body: empty + * Reply: { aborted: true, at_seq: number } (envelope code 0) + * { aborted: false, at_seq: number } (envelope code 40903, idempotent) + */ + +import { z } from 'zod'; + +import { messageContentSchema } from '../message'; +import { isoDateTimeSchema } from '../time'; + +export const promptThinkingSchema = z.enum([ + 'off', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +]); +export type PromptThinking = z.infer; + +export const promptPermissionModeSchema = z.enum(['manual', 'yolo', 'auto']); +export type PromptPermissionMode = z.infer; + +export const promptSubmissionSchema = z.object({ + content: z.array(messageContentSchema).min(1), + metadata: z.record(z.string(), z.unknown()).optional(), + model: z.string().min(1).optional(), + thinking: promptThinkingSchema.optional(), + permission_mode: promptPermissionModeSchema.optional(), + plan_mode: z.boolean().optional(), +}); +export type PromptSubmission = z.infer; + +export const promptStatusSchema = z.enum(['running', 'queued']); +export type PromptStatus = z.infer; + +export const promptItemSchema = z.object({ + prompt_id: z.string().min(1), + user_message_id: z.string().min(1), + status: promptStatusSchema, + content: z.array(messageContentSchema).min(1), + created_at: isoDateTimeSchema, +}); +export type PromptItem = z.infer; + +export const promptListResponseSchema = z.object({ + active: promptItemSchema.nullable(), + queued: z.array(promptItemSchema), +}); +export type PromptListResponse = z.infer; + +export const promptSubmitResultSchema = promptItemSchema; +export type PromptSubmitResult = z.infer; + +export const promptSteerRequestSchema = z.object({ + prompt_ids: z.array(z.string().min(1)).min(1), +}); +export type PromptSteerRequest = z.infer; + +export const promptSteerResultSchema = z.object({ + steered: z.literal(true), + prompt_ids: z.array(z.string().min(1)).min(1), +}); +export type PromptSteerResult = z.infer; + +export const promptAbortResponseSchema = z.object({ + aborted: z.boolean(), + at_seq: z.number().int().nonnegative().optional(), +}); +export type PromptAbortResponse = z.infer; + +export interface PromptCompletedEventPayload { + readonly type: 'prompt.completed'; + readonly agentId: string; + readonly sessionId: string; + readonly promptId: string; + readonly finishedAt: string; +} + +export interface PromptAbortedEventPayload { + readonly type: 'prompt.aborted'; + readonly agentId: string; + readonly sessionId: string; + readonly promptId: string; + readonly abortedAt: string; +} + +export interface PromptSteeredEventPayload { + readonly type: 'prompt.steered'; + readonly agentId: string; + readonly sessionId: string; + readonly activePromptId: string; + readonly promptIds: readonly string[]; + readonly content: PromptSubmission['content']; + readonly steeredAt: string; +} diff --git a/packages/protocol/src/rest/question.ts b/packages/protocol/src/rest/question.ts new file mode 100644 index 000000000..d77e0138e --- /dev/null +++ b/packages/protocol/src/rest/question.ts @@ -0,0 +1,48 @@ +/** + * GET /v1/sessions/{sid}/questions?status=pending + * Reply: { items: QuestionRequest[] } + * + * POST /v1/sessions/{sid}/questions/{qid} (resolve) + * Body: QuestionResponse (answers map + method? + note?) + * Reply: QuestionResolveResult { resolved: true, resolved_at } + * Errors: 40001 / 40404 / 40902 / 41002 + * + * POST /v1/sessions/{sid}/questions/{qid}:dismiss (dismiss) + * Body: empty + * Reply: envelope code: 40909 + data { dismissed: true, dismissed_at } + */ + +import { z } from 'zod'; + +import { questionRequestSchema, questionResponseSchema } from '../question'; +import { isoDateTimeSchema } from '../time'; + +export const listPendingQuestionsQuerySchema = z.object({ + status: z.literal('pending'), +}); +export type ListPendingQuestionsQuery = z.infer; + +export const listPendingQuestionsResponseSchema = z.object({ + items: z.array(questionRequestSchema), +}); +export type ListPendingQuestionsResponse = z.infer; + +export const questionResolveRequestSchema = questionResponseSchema; +export type QuestionResolveRequest = z.infer; + +export const questionResolveResultSchema = z.object({ + resolved: z.literal(true), + resolved_at: isoDateTimeSchema, +}); +export type QuestionResolveResult = z.infer; + +export const questionAlreadyResolvedDataSchema = z.object({ + resolved: z.literal(false), +}); +export type QuestionAlreadyResolvedData = z.infer; + +export const questionDismissResultSchema = z.object({ + dismissed: z.literal(true), + dismissed_at: isoDateTimeSchema, +}); +export type QuestionDismissResult = z.infer; diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts new file mode 100644 index 000000000..fc7d8a2aa --- /dev/null +++ b/packages/protocol/src/rest/session.ts @@ -0,0 +1,124 @@ +/** + * POST /v1/sessions body: SessionCreate data: Session + * GET /v1/sessions query: ListSessions data: Page + * GET /v1/sessions/{id} - data: Session + * GET /v1/sessions/{id}/profile - data: Session + * POST /v1/sessions/{id}/profile body: SessionUpdate data: Session + * POST /v1/sessions/{id}:fork body: SessionFork data: Session + * GET /v1/sessions/{id}/children query: ListSessions data: Page + * POST /v1/sessions/{id}/children body: SessionChild data: Session + * GET /v1/sessions/{id}/status - data: SessionStatus + * POST /v1/sessions/{id}:compact body: CompactSession data: {} + * POST /v1/sessions/{id}:undo body: UndoSession data: UndoSession + * DELETE /v1/sessions/{id} - data: { deleted: true } + */ + +import { z } from 'zod'; + +import { messageSchema } from '../message'; +import { cursorQuerySchema, pageResponseSchema } from '../pagination'; +import { + sessionChildCreateSchema, + sessionCreateSchema, + sessionForkSchema, + sessionSchema, + sessionStatusSchema, + sessionUpdateSchema, +} from '../session'; + +export const createSessionRequestSchema = sessionCreateSchema; +export type CreateSessionRequest = z.infer; + +export const createSessionResponseSchema = sessionSchema; +export type CreateSessionResponse = z.infer; + +export const listSessionsQuerySchema = cursorQuerySchema.and( + z.object({ + status: sessionStatusSchema.optional(), + }), +); +export type ListSessionsQuery = z.infer; + +export const getSessionResponseSchema = sessionSchema; +export type GetSessionResponse = z.infer; + +export const getSessionProfileResponseSchema = sessionSchema; +export type GetSessionProfileResponse = z.infer; + +export const updateSessionProfileRequestSchema = sessionUpdateSchema; +export type UpdateSessionProfileRequest = z.infer; + +export const updateSessionProfileResponseSchema = sessionSchema; +export type UpdateSessionProfileResponse = z.infer; + +export const updateSessionMetaRequestSchema = updateSessionProfileRequestSchema; +export type UpdateSessionMetaRequest = UpdateSessionProfileRequest; + +export const updateSessionMetaResponseSchema = updateSessionProfileResponseSchema; +export type UpdateSessionMetaResponse = UpdateSessionProfileResponse; + +export const updateSessionRequestSchema = sessionUpdateSchema; +export type UpdateSessionRequest = z.infer; + +export const updateSessionResponseSchema = sessionSchema; +export type UpdateSessionResponse = z.infer; + +export const forkSessionRequestSchema = sessionForkSchema; +export type ForkSessionRequest = z.infer; + +export const forkSessionResponseSchema = sessionSchema; +export type ForkSessionResponse = z.infer; + +export const listSessionChildrenQuerySchema = listSessionsQuerySchema; +export type ListSessionChildrenQuery = z.infer; + +export const listSessionChildrenResponseSchema = pageResponseSchema(sessionSchema); +export type ListSessionChildrenResponse = z.infer; + +export const createSessionChildRequestSchema = sessionChildCreateSchema; +export type CreateSessionChildRequest = z.infer; + +export const createSessionChildResponseSchema = sessionSchema; +export type CreateSessionChildResponse = z.infer; + +export const sessionStatusResponseSchema = z.object({ + model: z.string().optional(), + thinking_level: z.string(), + permission: z.string(), + plan_mode: z.boolean(), + context_tokens: z.number().int().nonnegative(), + max_context_tokens: z.number().int().nonnegative(), + context_usage: z.number().min(0).max(1), +}); +export type SessionStatusResponse = z.infer; + +export const compactSessionRequestSchema = z.preprocess( + (value) => value === undefined ? {} : value, + z.object({ + instruction: z.string().optional(), + }), +); +export type CompactSessionRequest = z.infer; + +export const compactSessionResponseSchema = z.object({}); +export type CompactSessionResponse = z.infer; + +export const undoSessionRequestSchema = z.preprocess( + (value) => value === undefined ? {} : value, + z.object({ + count: z.number().int().positive().default(1), + page_size: z.number().int().min(1).max(100).optional(), + }), +); +export type UndoSessionRequest = z.infer; + +export const undoSessionResponseSchema = z.object({ + messages: pageResponseSchema(messageSchema), + status: sessionStatusResponseSchema, +}); +export type UndoSessionResponse = z.infer; + +export const deleteSessionResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteSessionResponse = z.infer; diff --git a/packages/protocol/src/rest/task.ts b/packages/protocol/src/rest/task.ts new file mode 100644 index 000000000..f4e8bc5bc --- /dev/null +++ b/packages/protocol/src/rest/task.ts @@ -0,0 +1,46 @@ +/** + * GET /v1/sessions/{session_id}/tasks query: {status?} + * Response data: `{ items: BackgroundTask[] }` + * + * GET /v1/sessions/{session_id}/tasks/{task_id} query: {with_output?, output_bytes?} + * Response data: `BackgroundTask` + * Errors: 40406 (task.not_found) + * + * POST /v1/sessions/{session_id}/tasks/{task_id}:cancel + * Body: empty + * Response data: `{ cancelled: true }` + * Errors: 40406 (task.not_found), 40904 (task.already_finished) + */ + +import { z } from 'zod'; + +import { backgroundTaskSchema, backgroundTaskStatusSchema } from '../task'; + +export const listTasksQuerySchema = z.object({ + status: backgroundTaskStatusSchema.optional(), +}); +export type ListTasksQuery = z.infer; + +export const listTasksResponseSchema = z.object({ + items: z.array(backgroundTaskSchema), +}); +export type ListTasksResponse = z.infer; + +export const getTaskQuerySchema = z.object({ + with_output: z.coerce.boolean().optional(), + output_bytes: z.coerce.number().int().nonnegative().optional(), +}); +export type GetTaskQuery = z.infer; + +export const getTaskResponseSchema = backgroundTaskSchema; +export type GetTaskResponse = z.infer; + +export const cancelTaskResultSchema = z.object({ + cancelled: z.literal(true), +}); +export type CancelTaskResult = z.infer; + +export const taskAlreadyFinishedDataSchema = z.object({ + cancelled: z.literal(false), +}); +export type TaskAlreadyFinishedData = z.infer; diff --git a/packages/protocol/src/rest/tool.ts b/packages/protocol/src/rest/tool.ts new file mode 100644 index 000000000..986a3c781 --- /dev/null +++ b/packages/protocol/src/rest/tool.ts @@ -0,0 +1,38 @@ +/** + * GET /v1/tools + * Query: `{ session_id?: string }` — when omitted returns global tool list; + * when present returns the session-effective list (REST §3.8 line 430). + * Response data: `{ tools: ToolDescriptor[] }` + * + * GET /v1/mcp/servers + * Response data: `{ servers: McpServer[] }` + * + * POST /v1/mcp/servers/{mcp_server_id}:restart + * Body: empty + * Response data: `{ restarting: true }` (REST §3.8 line 442) + * Errors: 40408 mcp.server_not_found + */ + +import { z } from 'zod'; + +import { mcpServerSchema, toolDescriptorSchema } from '../tool'; + +export const listToolsQuerySchema = z.object({ + session_id: z.string().min(1).optional(), +}); +export type ListToolsQuery = z.infer; + +export const listToolsResponseSchema = z.object({ + tools: z.array(toolDescriptorSchema), +}); +export type ListToolsResponse = z.infer; + +export const listMcpServersResponseSchema = z.object({ + servers: z.array(mcpServerSchema), +}); +export type ListMcpServersResponse = z.infer; + +export const restartMcpServerResultSchema = z.object({ + restarting: z.literal(true), +}); +export type RestartMcpServerResult = z.infer; diff --git a/packages/protocol/src/rest/workspace.ts b/packages/protocol/src/rest/workspace.ts new file mode 100644 index 000000000..bd74ea932 --- /dev/null +++ b/packages/protocol/src/rest/workspace.ts @@ -0,0 +1,47 @@ +/** + * GET /v1/workspaces → ListWorkspacesResponse { items } + * POST /v1/workspaces body: WorkspaceCreate → Workspace + * PATCH /v1/workspaces/{workspace_id} body: WorkspaceUpdate → Workspace + * DELETE /v1/workspaces/{workspace_id} → { deleted: true } + * + * Errors: + * - 40001 validation.failed (root not absolute, name too long, etc.) + * - 40409 fs.path_not_found (POST /workspaces { root } where root doesn't exist) + * - 40410 workspace.not_found (PATCH/DELETE unknown id) + */ + +import { z } from 'zod'; + +import { + workspaceCreateSchema, + workspaceIdSchema, + workspaceSchema, + workspaceUpdateSchema, +} from '../workspace'; + +export const listWorkspacesResponseSchema = z.object({ + items: z.array(workspaceSchema), +}); +export type ListWorkspacesResponse = z.infer; + +export const createWorkspaceRequestSchema = workspaceCreateSchema; +export type CreateWorkspaceRequest = z.infer; + +export const createWorkspaceResponseSchema = workspaceSchema; +export type CreateWorkspaceResponse = z.infer; + +export const workspaceIdParamSchema = z.object({ + workspace_id: workspaceIdSchema, +}); +export type WorkspaceIdParam = z.infer; + +export const updateWorkspaceRequestSchema = workspaceUpdateSchema; +export type UpdateWorkspaceRequest = z.infer; + +export const updateWorkspaceResponseSchema = workspaceSchema; +export type UpdateWorkspaceResponse = z.infer; + +export const deleteWorkspaceResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteWorkspaceResponse = z.infer; diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts new file mode 100644 index 000000000..92df1d4ed --- /dev/null +++ b/packages/protocol/src/session.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; + +import { + promptPermissionModeSchema, + promptThinkingSchema, +} from './rest/prompt'; +import { isoDateTimeSchema } from './time'; +import { workspaceIdSchema } from './workspace'; + +export const sessionStatusSchema = z.enum([ + 'idle', + 'running', + 'awaiting_approval', + 'awaiting_question', + 'aborted', +]); + +export type SessionStatus = z.infer; + +export const sessionUsageSchema = z.object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + cache_read_tokens: z.number().int().nonnegative(), + cache_creation_tokens: z.number().int().nonnegative(), + total_cost_usd: z.number().nonnegative(), + context_tokens: z.number().int().nonnegative(), + context_limit: z.number().int().nonnegative(), + turn_count: z.number().int().nonnegative(), +}); + +export type SessionUsage = z.infer; + +export function emptySessionUsage(): SessionUsage { + return { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }; +} + +export const permissionRuleMatcherSchema = z.object({ + kind: z.enum(['command_prefix', 'path_glob', 'exact_input', 'always']), + value: z.string().optional(), +}); + +export const permissionRuleSchema = z.object({ + id: z.string().min(1), + tool_name: z.string().min(1), + matcher: permissionRuleMatcherSchema.optional(), + decision: z.literal('approved'), + created_at: isoDateTimeSchema, + created_by: z.enum(['user', 'agent']), +}); + +export type PermissionRule = z.infer; + +export const sessionAgentConfigSchema = z.object({ + model: z.string(), + system_prompt: z.string().optional(), + tools: z.array(z.string()).optional(), + mcp_servers: z.array(z.string()).optional(), + thinking: promptThinkingSchema.optional(), + permission_mode: promptPermissionModeSchema.optional(), + plan_mode: z.boolean().optional(), +}); + +export type SessionAgentConfig = z.infer; + +export const sessionAgentConfigPartialSchema = sessionAgentConfigSchema.partial(); +export type SessionAgentConfigPartial = z.infer; + +export const sessionMetadataSchema = z + .object({ + cwd: z.string().min(1), + }) + .catchall(z.unknown()); + +export type SessionMetadata = z.infer; + +export const sessionSchema = z.object({ + id: z.string().min(1), + workspace_id: workspaceIdSchema, + title: z.string(), + created_at: isoDateTimeSchema, + updated_at: isoDateTimeSchema, + status: sessionStatusSchema, + current_prompt_id: z.string().min(1).optional(), + metadata: sessionMetadataSchema, + agent_config: sessionAgentConfigSchema, + usage: sessionUsageSchema, + permission_rules: z.array(permissionRuleSchema), + message_count: z.number().int().nonnegative(), + last_seq: z.number().int().nonnegative(), +}); + +export type Session = z.infer; + +export const sessionCreateSchema = z.object({ + title: z.string().min(1).optional(), + metadata: sessionMetadataSchema.optional(), + agent_config: sessionAgentConfigPartialSchema.optional(), + workspace_id: workspaceIdSchema.optional(), +}); + +export type SessionCreate = z.infer; + +export const sessionUpdateSchema = z.object({ + title: z.string().min(1).optional(), + metadata: sessionMetadataSchema.partial().optional(), + agent_config: sessionAgentConfigPartialSchema.optional(), + permission_rules: z.array(permissionRuleSchema).optional(), +}); + +export type SessionUpdate = z.infer; + +export const sessionForkSchema = z.object({ + title: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type SessionFork = z.infer; + +export const sessionChildCreateSchema = z.object({ + title: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type SessionChildCreate = z.infer; diff --git a/packages/protocol/src/task.ts b/packages/protocol/src/task.ts new file mode 100644 index 000000000..8f6725527 --- /dev/null +++ b/packages/protocol/src/task.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const backgroundTaskKindSchema = z.enum(['subagent', 'bash', 'tool']); +export type BackgroundTaskKind = z.infer; + +export const backgroundTaskStatusSchema = z.enum([ + 'running', + 'completed', + 'failed', + 'cancelled', +]); +export type BackgroundTaskStatus = z.infer; + +export const backgroundTaskSchema = z.object({ + id: z.string().min(1), + session_id: z.string().min(1), + kind: backgroundTaskKindSchema, + description: z.string(), + status: backgroundTaskStatusSchema, + created_at: isoDateTimeSchema, + started_at: isoDateTimeSchema.optional(), + completed_at: isoDateTimeSchema.optional(), + output_preview: z.string().optional(), + output_bytes: z.number().int().nonnegative().optional(), +}); +export type BackgroundTask = z.infer; diff --git a/packages/protocol/src/time.ts b/packages/protocol/src/time.ts new file mode 100644 index 000000000..9d3547ac7 --- /dev/null +++ b/packages/protocol/src/time.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const ISO_8601_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/; + +export const isoDateTimeSchema = z + .string() + .refine((value) => ISO_8601_REGEX.test(value), { + message: 'must be an ISO 8601 datetime string', + }) + .transform((value, ctx) => { + const ms = Date.parse(value); + if (Number.isNaN(ms)) { + ctx.addIssue({ + code: 'custom', + message: 'invalid ISO 8601 datetime', + }); + return z.NEVER; + } + return new Date(ms).toISOString(); + }); + +export const IsoDateTime = isoDateTimeSchema; + +export type IsoDateTime = string; + +export function nowIsoDateTime(): IsoDateTime { + return new Date().toISOString(); +} diff --git a/packages/protocol/src/tool.ts b/packages/protocol/src/tool.ts new file mode 100644 index 000000000..92ad7fac0 --- /dev/null +++ b/packages/protocol/src/tool.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const toolSourceSchema = z.enum(['builtin', 'skill', 'mcp']); +export type ToolSource = z.infer; + +export const toolDescriptorSchema = z.object({ + name: z.string().min(1), + description: z.string(), + input_schema: z.unknown(), + source: toolSourceSchema, + mcp_server_id: z.string().min(1).optional(), +}); +export type ToolDescriptor = z.infer; + +export const mcpServerStatusSchema = z.enum([ + 'connected', + 'connecting', + 'disconnected', + 'error', +]); +export type McpServerStatus = z.infer; + +export const mcpServerTransportSchema = z.enum(['stdio', 'http', 'sse']); +export type McpServerTransport = z.infer; + +export const mcpServerSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + transport: mcpServerTransportSchema, + status: mcpServerStatusSchema, + last_error: z.string().optional(), + tool_count: z.number().int().nonnegative(), +}); +export type McpServer = z.infer; diff --git a/packages/protocol/src/workspace.ts b/packages/protocol/src/workspace.ts new file mode 100644 index 000000000..b349a20c2 --- /dev/null +++ b/packages/protocol/src/workspace.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const workspaceIdSchema = z + .string() + .regex(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, { + message: 'workspace_id must be a wd__ string', + }); + +export type WorkspaceId = z.infer; + +export const workspaceSchema = z.object({ + id: workspaceIdSchema, + root: z.string().min(1), + name: z.string().min(1).max(100), + is_git_repo: z.boolean(), + branch: z.string().nullable(), + created_at: isoDateTimeSchema, + last_opened_at: isoDateTimeSchema, + session_count: z.number().int().nonnegative(), +}); + +export type Workspace = z.infer; + +export const workspaceCreateSchema = z.object({ + root: z.string().min(1), + name: z.string().min(1).max(100).optional(), +}); + +export type WorkspaceCreate = z.infer; + +export const workspaceUpdateSchema = z.object({ + name: z.string().min(1).max(100), +}); + +export type WorkspaceUpdate = z.infer; diff --git a/packages/protocol/src/ws-control.ts b/packages/protocol/src/ws-control.ts new file mode 100644 index 000000000..5578a3325 --- /dev/null +++ b/packages/protocol/src/ws-control.ts @@ -0,0 +1,227 @@ +/** + * Event: { type, seq, session_id?, timestamp, payload } + * Control: { type, id?, payload } + * Ack: { type: 'ack', id, code, msg, payload } + */ +import { z } from 'zod'; + +import { isoDateTimeSchema } from './time'; + +export const wsEventEnvelopeSchema = (payload: T) => + z.object({ + type: z.string(), + seq: z.number().int().nonnegative(), + session_id: z.string().optional(), + timestamp: isoDateTimeSchema, + payload, + }); + +export const wsControlEnvelopeSchema = (payload: T) => + z.object({ + type: z.string(), + id: z.string().optional(), + payload, + }); + +export const wsAckEnvelopeSchema = (payload: T) => + z.object({ + type: z.literal('ack'), + id: z.string(), + code: z.number().int(), + msg: z.string(), + payload, + }); + +export const serverHelloPayloadSchema = z.object({ + ws_connection_id: z.string(), + heartbeat_ms: z.number().int().positive(), + max_event_buffer_size: z.number().int().positive(), + capabilities: z.object({ + event_batching: z.boolean(), + compression: z.boolean(), + }), +}); + +export const serverHelloMessageSchema = z.object({ + type: z.literal('server_hello'), + timestamp: isoDateTimeSchema, + payload: serverHelloPayloadSchema, +}); + +export type ServerHelloMessage = z.infer; + +export const clientHelloPayloadSchema = z.object({ + client_id: z.string(), + subscriptions: z.array(z.string()), + last_seq_by_session: z.record(z.string(), z.number().int().nonnegative()).optional(), +}); + +export const clientHelloMessageSchema = z.object({ + type: z.literal('client_hello'), + id: z.string(), + payload: clientHelloPayloadSchema, +}); + +export type ClientHelloMessage = z.infer; + +export const helloAckPayloadSchema = z.object({ + accepted_subscriptions: z.array(z.string()).optional(), + accepted: z.array(z.string()).optional(), + not_found: z.array(z.string()).optional(), + resync_required: z.array(z.string()), +}); + +export const watchFsConfigSchema = z.object({ + paths: z.array(z.string()), + recursive: z.boolean().optional(), +}); + +export const subscribePayloadSchema = z.object({ + session_ids: z.array(z.string()), + last_seq_by_session: z.record(z.string(), z.number().int().nonnegative()).optional(), + watch_fs: z.record(z.string(), watchFsConfigSchema).optional(), +}); + +export const subscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: z.string(), + payload: subscribePayloadSchema, +}); + +export type SubscribeMessage = z.infer; + +export const unsubscribePayloadSchema = z.object({ + session_ids: z.array(z.string()), +}); + +export const unsubscribeMessageSchema = z.object({ + type: z.literal('unsubscribe'), + id: z.string(), + payload: unsubscribePayloadSchema, +}); + +export type UnsubscribeMessage = z.infer; + +export const watchFsAddPayloadSchema = z.object({ + session_id: z.string(), + paths: z.array(z.string()), + recursive: z.boolean().optional(), +}); + +export const watchFsAddMessageSchema = z.object({ + type: z.literal('watch_fs_add'), + id: z.string(), + payload: watchFsAddPayloadSchema, +}); + +export type WatchFsAddMessage = z.infer; + +export const watchFsRemovePayloadSchema = z.object({ + session_id: z.string(), + paths: z.array(z.string()), +}); + +export const watchFsRemoveMessageSchema = z.object({ + type: z.literal('watch_fs_remove'), + id: z.string(), + payload: watchFsRemovePayloadSchema, +}); + +export type WatchFsRemoveMessage = z.infer; + +export const watchFsAckPayloadSchema = z.object({ + watched_paths: z.array(z.string()).optional(), + current_count: z.number().int().nonnegative().optional(), +}); + +export const abortPayloadSchema = z.object({ + session_id: z.string(), + prompt_id: z.string(), +}); + +export const abortMessageSchema = z.object({ + type: z.literal('abort'), + id: z.string(), + payload: abortPayloadSchema, +}); + +export type AbortMessage = z.infer; + +export const abortAckPayloadSchema = z.object({ + aborted: z.boolean().optional(), + at_seq: z.number().int().nonnegative().optional(), +}); + +export const pingPayloadSchema = z.object({ + nonce: z.string(), +}); + +export const pingMessageSchema = z.object({ + type: z.literal('ping'), + timestamp: isoDateTimeSchema, + payload: pingPayloadSchema, +}); + +export type PingMessage = z.infer; + +export const pongPayloadSchema = z.object({ + nonce: z.string(), +}); + +export const pongMessageSchema = z.object({ + type: z.literal('pong'), + payload: pongPayloadSchema, +}); + +export type PongMessage = z.infer; + +export const resyncRequiredPayloadSchema = z.object({ + session_id: z.string(), + reason: z.enum(['buffer_overflow', 'session_recreated']), + current_seq: z.number().int().nonnegative(), +}); + +export const resyncRequiredMessageSchema = z.object({ + type: z.literal('resync_required'), + timestamp: isoDateTimeSchema, + payload: resyncRequiredPayloadSchema, +}); + +export type ResyncRequiredMessage = z.infer; + +export const wsErrorPayloadSchema = z.object({ + code: z.number().int(), + msg: z.string(), + fatal: z.boolean(), + request_id: z.string().optional(), + details: z.unknown().optional(), +}); + +export const wsErrorMessageSchema = z.object({ + type: z.literal('error'), + timestamp: isoDateTimeSchema, + payload: wsErrorPayloadSchema, +}); + +export type WsErrorMessage = z.infer; + +export const clientControlMessageSchema = z.discriminatedUnion('type', [ + clientHelloMessageSchema, + subscribeMessageSchema, + unsubscribeMessageSchema, + watchFsAddMessageSchema, + watchFsRemoveMessageSchema, + abortMessageSchema, + pongMessageSchema, +]); + +export type ClientControlMessage = z.infer; + +export const serverSystemMessageSchema = z.discriminatedUnion('type', [ + serverHelloMessageSchema, + pingMessageSchema, + resyncRequiredMessageSchema, + wsErrorMessageSchema, +]); + +export type ServerSystemMessage = z.infer; diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 000000000..8218f8155 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"] + }, + "include": ["src"] +} diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts new file mode 100644 index 000000000..b27e00afd --- /dev/null +++ b/packages/protocol/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: false, + outDir: 'dist', + clean: true, + deps: { + alwaysBundle: [/^@moonshot-ai\//], + neverBundle: [], + }, +}); diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts new file mode 100644 index 000000000..de71f3809 --- /dev/null +++ b/packages/protocol/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +export default defineConfig({ + plugins: [rawTextPlugin()], + test: { + name: 'protocol', + include: ['src/__tests__/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c4047ff2..e4f2ea795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: '@moonshot-ai/kosong': specifier: workspace:^ version: link:../kosong + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -422,6 +425,15 @@ importers: specifier: ^4.1.4 version: 4.1.4 + packages/protocol: + dependencies: + ulid: + specifier: ^3.0.1 + version: 3.0.2 + zod: + specifier: 'catalog:' + version: 4.3.6 + packages/telemetry: {} packages: @@ -5155,6 +5167,10 @@ packages: uhyphen@0.2.0: resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + ulid@3.0.2: + resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10364,6 +10380,8 @@ snapshots: uhyphen@0.2.0: {} + ulid@3.0.2: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 From 494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 10 Jun 2026 14:22:02 +0800 Subject: [PATCH 018/476] feat: add undo selector (#615) --- .changeset/blue-undo-selector.md | 6 + apps/kimi-code/src/tui/commands/dispatch.ts | 8 +- apps/kimi-code/src/tui/commands/undo.ts | 295 +++++++++++++++++- .../tui/components/dialogs/undo-selector.ts | 120 +++++++ .../test/tui/kimi-tui-message-flow.test.ts | 24 ++ docs/en/reference/slash-commands.md | 1 + docs/zh/reference/slash-commands.md | 1 + .../agent-core/src/agent/context/index.ts | 23 +- .../agent-core/test/agent/context.test.ts | 4 +- 9 files changed, 475 insertions(+), 7 deletions(-) create mode 100644 .changeset/blue-undo-selector.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/undo-selector.ts diff --git a/.changeset/blue-undo-selector.md b/.changeset/blue-undo-selector.md new file mode 100644 index 000000000..635f5a1a2 --- /dev/null +++ b/.changeset/blue-undo-selector.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add an interactive undo selector and clearer undo-limit messages. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 1a50f510b..397404e0f 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -18,7 +18,12 @@ import type { AuthFlowController } from '../controllers/auth-flow'; import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; -import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; +import type { + AppState, + LoginProgressSpinnerHandle, + QueuedMessage, + TranscriptEntry, +} from '../types'; import type { TUIState } from '../tui-state'; import { handleLoginCommand, handleLogoutCommand } from './auth'; @@ -109,6 +114,7 @@ export interface SlashCommandHost { showError(msg: string): void; showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; track(event: string, props?: Record): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index c3228b139..5a1c963d7 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -1,6 +1,13 @@ import type { Component } from '@earendil-works/pi-tui'; +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; import { WelcomeComponent } from '../components/chrome/welcome'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { + UndoSelectorComponent, + type UndoChoice, +} from '../components/dialogs/undo-selector'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; @@ -15,12 +22,24 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import type { TranscriptEntry } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata'; +import { nextTranscriptId } from '../utils/transcript-id'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Undo command // --------------------------------------------------------------------------- +interface UndoAvailability { + readonly maxCount: number; + readonly stoppedAtCompaction: boolean; +} + +type UndoSessionContext = Awaited< + ReturnType['getContext']> +>; + +const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status'; + export async function handleUndoCommand( host: SlashCommandHost, args: string = '', @@ -30,7 +49,13 @@ export async function handleUndoCommand( return; } - const count = parseUndoCount(args); + const trimmed = args.trim(); + if (trimmed.length === 0) { + await showUndoSelector(host); + return; + } + + const count = parseUndoCount(trimmed); if (count === undefined) { host.showError('Usage: /undo [count], where count is a positive integer.'); return; @@ -42,19 +67,40 @@ export async function handleUndoCommand( return; } + const availability = await resolveUndoAvailability(host); + if (count > availability.maxCount) { + showUndoLimitStatus(host, formatUndoLimitMessage(count, availability)); + return; + } + + await undoByCount(host, count); +} + +async function undoByCount(host: SlashCommandHost, count: number): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return false; + } + const entries = host.state.transcriptEntries; const lastUserIndex = findUndoAnchorEntryIndex(entries, count); if (lastUserIndex === undefined) { - host.showError('Nothing to undo.'); - return; + showUndoLimitStatus(host, 'Nothing to undo.'); + return false; } try { await session.undoHistory(count); } catch (error) { + const limit = undoLimitFromError(error); + if (limit !== undefined) { + showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); + return false; + } const message = formatErrorMessage(error); host.showError(`Failed to undo: ${message}`); - return; + return false; } const children = host.state.transcriptContainer.children; @@ -74,6 +120,43 @@ export async function handleUndoCommand( } host.state.ui.requestRender(); + return true; +} + +async function showUndoSelector(host: SlashCommandHost): Promise { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + const choices = createUndoChoices( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + availability.maxCount, + ); + if (choices.length === 0) { + showUndoLimitStatus(host, formatNothingToUndoMessage(availability)); + return; + } + + host.mountEditorReplacement( + new UndoSelectorComponent({ + choices, + onSelect: (choice) => { + void undoByCount(host, choice.count).then((undone) => { + if (undone) { + host.restoreInputText(choice.input); + return; + } + host.restoreEditor(); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); } function parseUndoCount(args: string): number | undefined { @@ -84,6 +167,210 @@ function parseUndoCount(args: string): number | undefined { return Number.isSafeInteger(count) ? count : undefined; } +async function resolveUndoAvailability( + host: SlashCommandHost, +): Promise { + const local = undoAvailabilityFromTranscript( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + ); + const context = await getSessionContext(host.session); + if (context === undefined) return local; + + const activeContext = undoAvailabilityFromContext(context.history); + return { + maxCount: Math.min(local.maxCount, activeContext.maxCount), + stoppedAtCompaction: + local.stoppedAtCompaction || activeContext.stoppedAtCompaction, + }; +} + +async function getSessionContext( + session: SlashCommandHost['session'], +): Promise { + const getContext = ( + session as { getContext?: () => Promise } | undefined + )?.getContext; + if (session === undefined || getContext === undefined) return undefined; + try { + return await getContext.call(session); + } catch { + return undefined; + } +} + +function undoAvailabilityFromTranscript( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): UndoAvailability { + const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children); + return { + maxCount: anchors.length, + stoppedAtCompaction, + }; +} + +function undoAvailabilityFromContext( + history: readonly ContextMessage[], +): UndoAvailability { + let maxCount = 0; + let stoppedAtCompaction = false; + + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]; + if (message === undefined) continue; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isContextUndoAnchor(message)) maxCount++; + } + + return { maxCount, stoppedAtCompaction }; +} + +function isContextUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined || origin.kind === 'user') return true; + if (origin.kind === 'skill_activation') { + return origin.trigger === 'user-slash'; + } + return false; +} + +function createUndoChoices( + entries: readonly TranscriptEntry[], + children: readonly Component[], + maxCount: number, +): readonly UndoChoice[] { + if (maxCount <= 0) return []; + const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount); + return anchors.map((entry, index) => ({ + id: entry.id, + count: anchors.length - index, + input: formatUndoChoiceInput(entry), + label: formatUndoChoiceLabel(entry), + })); +} + +function activeUndoAnchorEntries( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } { + const lastCompactionChildIndex = children.findLastIndex( + (child) => child instanceof CompactionComponent, + ); + if (lastCompactionChildIndex >= 0) { + return { + anchors: children + .slice(lastCompactionChildIndex + 1) + .map((child) => getTranscriptComponentEntry(child)) + .filter((entry): entry is TranscriptEntry => entry !== undefined) + .filter(isUndoAnchorEntry), + stoppedAtCompaction: true, + }; + } + + const lastCompactionEntryIndex = entries.findLastIndex( + (entry) => entry.compactionData !== undefined, + ); + const activeEntries = + lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries; + return { + anchors: activeEntries.filter(isUndoAnchorEntry), + stoppedAtCompaction: lastCompactionEntryIndex >= 0, + }; +} + +function formatUndoChoiceLabel( + entry: TranscriptEntry, +): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return 'Skill: unknown'; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + + const content = singleLine(entry.content); + const imageCount = entry.imageAttachmentIds?.length ?? 0; + if (content.length > 0) return content; + if (imageCount > 0) { + return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`; + } + return 'User message'; +} + +function formatUndoChoiceInput(entry: TranscriptEntry): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return ''; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + return entry.content; +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function formatUndoLimitMessage( + requestedCount: number, + availability: UndoAvailability, +): string { + const reason = availability.stoppedAtCompaction ? ' after the last compaction' : ''; + const requested = formatPromptCount(requestedCount); + const max = formatPromptCount(availability.maxCount); + return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`; +} + +function formatNothingToUndoMessage(availability: UndoAvailability): string { + if (availability.stoppedAtCompaction) { + return 'Nothing to undo after the last compaction.'; + } + return 'Nothing to undo.'; +} + +function formatPromptCount(count: number): string { + return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`; +} + +function showUndoLimitStatus(host: SlashCommandHost, message: string): void { + host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: UNDO_LIMIT_STATUS_TURN_ID, + renderMode: 'plain', + content: message, + }); +} + +function undoLimitFromError( + error: unknown, +): (UndoAvailability & { readonly requestedCount: number }) | undefined { + if (!isKimiError(error)) return undefined; + const details = error.details; + if (details?.['reason'] !== 'undo_limit') return undefined; + const requestedCount = details['requestedCount']; + const maxCount = details['undoableCount']; + const stoppedAtCompaction = details['stoppedAtCompaction']; + if ( + typeof requestedCount !== 'number' || + typeof maxCount !== 'number' || + typeof stoppedAtCompaction !== 'boolean' + ) { + return undefined; + } + return { requestedCount, maxCount, stoppedAtCompaction }; +} + function isUndoAnchorEntry(entry: TranscriptEntry): boolean { return ( entry.kind === 'user' || diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts new file mode 100644 index 000000000..77d82ccdd --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -0,0 +1,120 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_VISIBLE_CHOICES = 5; +const PREFERRED_SELECTED_OFFSET = 2; + +export interface UndoChoice { + readonly id: string; + readonly count: number; + readonly input: string; + readonly label: string; +} + +export interface UndoSelectorOptions { + readonly choices: readonly UndoChoice[]; + readonly onSelect: (choice: UndoChoice) => void; + readonly onCancel: () => void; +} + +export class UndoSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: UndoSelectorOptions; + private readonly list: SearchableList; + private submitted = false; + + constructor(opts: UndoSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.choices, + toSearchText: (choice) => choice.label, + initialIndex: Math.max(0, opts.choices.length - 1), + }); + } + + handleInput(data: string): void { + if (this.submitted) return; + + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + if (this.list.handleKey(data)) { + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.list.selected(); + if (selected !== undefined) { + this.submitted = true; + this.opts.onSelect(selected); + } + } + } + + override render(width: number): string[] { + const view = this.list.view(); + const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select messages to undo'), + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + '', + ]; + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No messages')); + } else { + const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length); + const maxStart = view.items.length - visibleCount; + const start = Math.min( + Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET), + maxStart, + ); + const end = start + visibleCount; + + for (let i = start; i < end; i++) { + const choice = view.items[i]; + if (choice === undefined) continue; + lines.push( + this.renderChoiceLine(choice, i === view.selectedIndex, i > view.selectedIndex, width), + ); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderChoiceLine( + choice: UndoChoice, + isSelected: boolean, + inUndoRange: boolean, + width: number, + ): string { + const pointer = isSelected ? SELECT_POINTER : ' '; + const prefix = ` ${pointer} `; + const labelBudget = Math.max(8, width - visibleWidth(prefix)); + const label = truncateToWidth(choice.label, labelBudget, '…'); + const token = isSelected ? 'primary' : inUndoRange ? 'textDim' : 'text'; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix); + line += isSelected + ? currentTheme.boldFg(token, label) + : currentTheme.fg(token, label); + return line; + } +} diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0d638102b..0fd3b4a47 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -20,6 +20,7 @@ import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; +import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { PluginMcpSelectorComponent, PluginMarketplaceSelectorComponent, @@ -250,6 +251,13 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +async function confirmUndoSelection(driver: MessageDriver): Promise { + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); + }); + (driver.state.editorContainer.children[0] as UndoSelectorComponent).handleInput('\r'); +} + function renderActivity(driver: MessageDriver): string { return driver.state.activityContainer.render(120).join('\n'); } @@ -802,6 +810,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -829,6 +838,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([]); @@ -852,7 +862,15 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); }); + driver.handleUserInput('/undo 10'); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', + ); + }); + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -860,6 +878,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Cannot undo 10 prompts'); expect(transcript).toContain('Auto mode: ON'); expect(driver.state.appState.permissionMode).toBe('auto'); }); @@ -897,6 +916,7 @@ command = "vim" }); driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -943,6 +963,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -986,6 +1007,7 @@ command = "vim" }); driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -1064,6 +1086,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([]); @@ -1092,6 +1115,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([ diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 46e60d78f..36cd41421 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -32,6 +32,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No | | `/title []` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes | | `/compact []` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No | +| `/undo []` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone | No | | `/reload` | — | Reload the current session and apply the latest `config.toml` settings (providers, models, etc.) and `tui.toml` UI preferences, without restarting the CLI | No | | `/reload-tui` | — | Reload only the `tui.toml` UI preferences (theme, editor, notifications, etc.) without rebuilding the session | Yes | | `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 8033ca00b..9bd4ef343 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -32,6 +32,7 @@ | `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 | | `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 | | `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 | +| `/undo []` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销 | 否 | | `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 | | `/export-md []` | `/export` | 将当前会话导出为 Markdown 文件 | 否 | | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 6beb0a6ea..19ca54f07 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -133,7 +133,15 @@ export class ContextMemory { ) { throw new KimiError( ErrorCodes.REQUEST_INVALID, - 'Nothing to undo in the active context.', + formatUndoUnavailableMessage(count, removedUserCount, stoppedAtBoundary), + { + details: { + reason: 'undo_limit', + requestedCount: count, + undoableCount: removedUserCount, + stoppedAtCompaction: stoppedAtBoundary, + }, + }, ); } } @@ -345,3 +353,16 @@ function isRealUserPrompt(message: ContextMessage): boolean { } return false; } + +function formatUndoUnavailableMessage( + requestedCount: number, + undoableCount: number, + stoppedAtCompaction: boolean, +): string { + const reason = stoppedAtCompaction ? ' after the last compaction' : ''; + return `Cannot undo ${formatPromptCount(requestedCount)}; only ${formatPromptCount(undoableCount)} can be undone in the active context${reason}.`; + + function formatPromptCount(count: number): string { + return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`; + } +} diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 610b97caf..2a8a33c71 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -561,7 +561,9 @@ describe('Agent context', () => { expect(() => { ctx.agent.context.undo(2); - }).toThrow('Nothing to undo in the active context.'); + }).toThrow( + 'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.', + ); expect(ctx.agent.context.history).toEqual([ expect.objectContaining({ From b85382360916c7f7b6ce55fdf7a767363d1700cb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 10 Jun 2026 14:44:51 +0800 Subject: [PATCH 019/476] chore: undo selector as a patch (#618) --- .changeset/blue-undo-selector.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/blue-undo-selector.md b/.changeset/blue-undo-selector.md index 635f5a1a2..dffba3c09 100644 --- a/.changeset/blue-undo-selector.md +++ b/.changeset/blue-undo-selector.md @@ -1,6 +1,6 @@ --- -"@moonshot-ai/agent-core": minor -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch --- Add an interactive undo selector and clearer undo-limit messages. From 1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 15:09:33 +0800 Subject: [PATCH 020/476] fix: truncate goal marker text to terminal width (#619) --- .../fix-goal-marker-width-truncation.md | 5 ++++ .../tui/components/messages/goal-markers.ts | 23 ++++++++++++------- .../components/messages/goal-markers.test.ts | 17 ++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-goal-marker-width-truncation.md diff --git a/.changeset/fix-goal-marker-width-truncation.md b/.changeset/fix-goal-marker-width-truncation.md new file mode 100644 index 000000000..0061836a6 --- /dev/null +++ b/.changeset/fix-goal-marker-width-truncation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix goal marker text overflowing terminal width. diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 1aee9969b..0cff92d50 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -7,7 +7,7 @@ * the richer completion card (the `/goal` box), not this marker. */ -import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; @@ -58,22 +58,29 @@ export class GoalMarkerComponent implements Component { const dot = currentTheme.fg(this.accentToken, this.marker); const head = currentTheme.fg(this.textToken, this.headline); const hasDetail = this.detail !== undefined && this.detail.length > 0; - if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + if (!hasDetail) return this.clampToWidth([`${this.indent}${dot} ${head}`], width); if (!this.expandable) { - return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + return this.clampToWidth([`${this.indent}${dot} ${head}`], width); } if (!this.expanded) { - return this.withLeadingBlank([ - `${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`, - ]); + return this.clampToWidth( + [`${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`], + width, + ); } const out = [`${this.indent}${dot} ${head}`]; const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); for (const line of wrap(this.detail!, wrapWidth)) { out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); } - return this.withLeadingBlank(out); + return this.clampToWidth(out, width); + } + + private clampToWidth(lines: string[], width: number): string[] { + const withBlank = this.withLeadingBlank(lines); + if (width <= 0) return withBlank.map(() => ''); + return withBlank.map((line) => truncateToWidth(line, width)); } private withLeadingBlank(lines: string[]): string[] { @@ -163,7 +170,7 @@ function lowercaseFirst(text: string): string { } function wrap(text: string, width: number): string[] { - const words = text.replace(/\s+/g, ' ').trim().split(' '); + const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); const lines: string[] = []; let current = ''; for (const word of words) { diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 7f1517629..73d87f394 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; @@ -47,6 +48,22 @@ describe('buildGoalMarker', () => { expect(strip(marker!.render(80))).toBe('\n● Goal paused after runtime error: socket hang up'); }); + it('keeps long provider pause markers within the terminal width', () => { + const reason = + 'Paused after provider API error: 400 {"error":{"message":"request id: 456043b9-6491-11f1-9425-2221bb1af97c, \\"thinking.enabled\\" is not supported for this model. Use \\"thinking.adaptive\\" and \\"output_config.effort\\" to control thinking behavior.","type":"invalid_request_error"}}'; + const marker = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason } as GoalChange, + false, + 'runtime', + ); + + const width = 80; + expect(strip(marker!.render(width))).toContain('Goal paused after provider API error'); + for (const line of marker!.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + it('attributes model pause and resume markers to the agent', () => { const paused = buildGoalMarker( { kind: 'lifecycle', status: 'paused' } as GoalChange, From 3f9226f0149d5921d3c9fe25261d718759717a8f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:11:40 +0800 Subject: [PATCH 021/476] ci: release packages (#608) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/anthropic-fable-5.md | 6 ------ .changeset/blue-undo-selector.md | 6 ------ .changeset/clear-skill-reminders.md | 6 ------ .changeset/datasource-env-oauth.md | 6 ------ .../fix-goal-marker-width-truncation.md | 5 ----- .changeset/protocol-and-fork-guard.md | 7 ------- .changeset/tighten-file-tool-guidance.md | 6 ------ .changeset/yolo-outside-write-approval.md | 6 ------ apps/kimi-code/CHANGELOG.md | 20 +++++++++++++++++++ apps/kimi-code/package.json | 2 +- packages/agent-core/CHANGELOG.md | 20 +++++++++++++++++++ packages/agent-core/package.json | 2 +- packages/kosong/CHANGELOG.md | 6 ++++++ packages/kosong/package.json | 2 +- packages/protocol/CHANGELOG.md | 7 +++++++ packages/protocol/package.json | 2 +- 16 files changed, 57 insertions(+), 52 deletions(-) delete mode 100644 .changeset/anthropic-fable-5.md delete mode 100644 .changeset/blue-undo-selector.md delete mode 100644 .changeset/clear-skill-reminders.md delete mode 100644 .changeset/datasource-env-oauth.md delete mode 100644 .changeset/fix-goal-marker-width-truncation.md delete mode 100644 .changeset/protocol-and-fork-guard.md delete mode 100644 .changeset/tighten-file-tool-guidance.md delete mode 100644 .changeset/yolo-outside-write-approval.md create mode 100644 packages/protocol/CHANGELOG.md diff --git a/.changeset/anthropic-fable-5.md b/.changeset/anthropic-fable-5.md deleted file mode 100644 index 1387c31ef..000000000 --- a/.changeset/anthropic-fable-5.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Add Claude Fable 5 support to the Anthropic provider. diff --git a/.changeset/blue-undo-selector.md b/.changeset/blue-undo-selector.md deleted file mode 100644 index dffba3c09..000000000 --- a/.changeset/blue-undo-selector.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Add an interactive undo selector and clearer undo-limit messages. diff --git a/.changeset/clear-skill-reminders.md b/.changeset/clear-skill-reminders.md deleted file mode 100644 index b61b1ef4a..000000000 --- a/.changeset/clear-skill-reminders.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Clarify active skill prompts so loaded skills are no longer represented as system reminders. diff --git a/.changeset/datasource-env-oauth.md b/.changeset/datasource-env-oauth.md deleted file mode 100644 index 2a34572ad..000000000 --- a/.changeset/datasource-env-oauth.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/agent-core": patch ---- - -Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. diff --git a/.changeset/fix-goal-marker-width-truncation.md b/.changeset/fix-goal-marker-width-truncation.md deleted file mode 100644 index 0061836a6..000000000 --- a/.changeset/fix-goal-marker-width-truncation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix goal marker text overflowing terminal width. diff --git a/.changeset/protocol-and-fork-guard.md b/.changeset/protocol-and-fork-guard.md deleted file mode 100644 index 4d8d2e0bf..000000000 --- a/.changeset/protocol-and-fork-guard.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/protocol": minor -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. diff --git a/.changeset/tighten-file-tool-guidance.md b/.changeset/tighten-file-tool-guidance.md deleted file mode 100644 index 6ea5b6d3d..000000000 --- a/.changeset/tighten-file-tool-guidance.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Tighten file tool guidance to route incremental edits through Edit. diff --git a/.changeset/yolo-outside-write-approval.md b/.changeset/yolo-outside-write-approval.md deleted file mode 100644 index 6ad245bd4..000000000 --- a/.changeset/yolo-outside-write-approval.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -YOLO mode no longer asks before writing or editing files outside the working directory. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 24588ae58..53e59fd9d 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 0.13.1 + +### Patch Changes + +- [#610](https://github.com/MoonshotAI/kimi-code/pull/610) [`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. + +- [#615](https://github.com/MoonshotAI/kimi-code/pull/615) [`494554e`](https://github.com/MoonshotAI/kimi-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. + +- [#598](https://github.com/MoonshotAI/kimi-code/pull/598) [`32d7080`](https://github.com/MoonshotAI/kimi-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. + +- [#595](https://github.com/MoonshotAI/kimi-code/pull/595) [`1580f35`](https://github.com/MoonshotAI/kimi-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. + +- [#619](https://github.com/MoonshotAI/kimi-code/pull/619) [`1fbe0e4`](https://github.com/MoonshotAI/kimi-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width. + +- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. + +- [#540](https://github.com/MoonshotAI/kimi-code/pull/540) [`2ebe387`](https://github.com/MoonshotAI/kimi-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. + +- [#606](https://github.com/MoonshotAI/kimi-code/pull/606) [`a1b419a`](https://github.com/MoonshotAI/kimi-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. + ## 0.13.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e0ea455a2..ab05311c4 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.13.0", + "version": "0.13.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/packages/agent-core/CHANGELOG.md b/packages/agent-core/CHANGELOG.md index 99907deef..872a15237 100644 --- a/packages/agent-core/CHANGELOG.md +++ b/packages/agent-core/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/agent-core +## 0.12.1 + +### Patch Changes + +- [#615](https://github.com/MoonshotAI/kimi-code/pull/615) [`494554e`](https://github.com/MoonshotAI/kimi-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. + +- [#598](https://github.com/MoonshotAI/kimi-code/pull/598) [`32d7080`](https://github.com/MoonshotAI/kimi-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. + +- [#595](https://github.com/MoonshotAI/kimi-code/pull/595) [`1580f35`](https://github.com/MoonshotAI/kimi-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. + +- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. + +- [#540](https://github.com/MoonshotAI/kimi-code/pull/540) [`2ebe387`](https://github.com/MoonshotAI/kimi-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. + +- [#606](https://github.com/MoonshotAI/kimi-code/pull/606) [`a1b419a`](https://github.com/MoonshotAI/kimi-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. + +- Updated dependencies [[`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b), [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14)]: + - @moonshot-ai/kosong@0.4.2 + - @moonshot-ai/protocol@0.2.0 + ## 0.12.0 ### Minor Changes diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index ccec3267e..a74f15af6 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/agent-core", - "version": "0.12.0", + "version": "0.12.1", "private": true, "description": "The unified agent engine for Kimi", "license": "MIT", diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index 6f5bd4d21..11aa57f0d 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kosong +## 0.4.2 + +### Patch Changes + +- [#610](https://github.com/MoonshotAI/kimi-code/pull/610) [`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. + ## 0.4.1 ### Patch Changes diff --git a/packages/kosong/package.json b/packages/kosong/package.json index bebdf7f78..8b52d1eb1 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kosong", - "version": "0.4.1", + "version": "0.4.2", "private": true, "description": "The LLM abstraction layer for modern AI agent applications", "license": "MIT", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md new file mode 100644 index 000000000..8d1248d87 --- /dev/null +++ b/packages/protocol/CHANGELOG.md @@ -0,0 +1,7 @@ +# @moonshot-ai/protocol + +## 0.2.0 + +### Minor Changes + +- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 6ccfb8d53..498aa4707 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/protocol", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Shared REST + WS protocol schemas (envelope, error codes, pagination, ws-control) for the kimi-code daemon.", "license": "MIT", From b253a82a7a5f7d91883dc77a30b8b38f8b6e1470 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 10 Jun 2026 15:35:25 +0800 Subject: [PATCH 022/476] feat(agent-core): add Interrupt hook for user-interrupted turns (#607) Fires an observation-only Interrupt event when a turn is aborted by the user (e.g. pressing Esc). Previously neither Stop nor StopFailure fired on interrupt, so external tooling that tracks status from hooks stayed stuck on a working state. --- .changeset/add-interrupt-hook.md | 5 ++ docs/en/customization/hooks.md | 1 + docs/zh/customization/hooks.md | 1 + packages/agent-core/src/agent/turn/index.ts | 13 +++- .../agent-core/src/session/hooks/types.ts | 1 + packages/agent-core/test/agent/turn.test.ts | 66 +++++++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-interrupt-hook.md diff --git a/.changeset/add-interrupt-hook.md b/.changeset/add-interrupt-hook.md new file mode 100644 index 000000000..7ee37fb00 --- /dev/null +++ b/.changeset/add-interrupt-hook.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 18db69817..1f966e341 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -110,6 +110,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return | `SubagentStart` | Sub-agent name | — | Triggered before a sub-agent starts running | | `SubagentStop` | Sub-agent name | — | Triggered after a sub-agent completes successfully (observation only) | | `StopFailure` | Error type | — | Triggered after the current turn fails due to an error (observation only) | +| `Interrupt` | Empty string | — | Triggered when the user interrupts the current turn (e.g. pressing Esc); not fired for timeouts or other programmatic aborts. `Stop` does not fire on interrupts, so this event fires instead. The payload includes a `reason` field (observation only) | | `PreCompact` | `manual` or `auto` | — | Triggered before context compaction begins; return values are completely ignored | | `PostCompact` | `manual` or `auto` | — | Triggered after context compaction completes (observation only) | | `Notification` | Notification type (e.g. `task.completed`) | — | Triggered when a background task status changes (observation only) | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index 0d12f2110..a506923b9 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -110,6 +110,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上 | `SubagentStart` | 子 Agent 名称 | — | 子 Agent 开始运行前触发 | | `SubagentStop` | 子 Agent 名称 | — | 子 Agent 成功完成后触发(观察用) | | `StopFailure` | 错误类型 | — | 本轮因错误失败后触发(观察用) | +| `Interrupt` | 空字符串 | — | 用户中断本轮时触发(例如按下 Esc);超时或其他程序性中断不会触发。中断时 `Stop` 不会触发,由本事件替代。payload 含 `reason` 字段(观察用) | | `PreCompact` | `manual` 或 `auto` | — | 上下文压缩开始前触发;返回值被完全忽略 | | `PostCompact` | `manual` 或 `auto` | — | 上下文压缩完成后触发(观察用) | | `Notification` | 通知类型(如 `task.completed`) | — | 后台任务状态变化时触发(观察用) | diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index e583a83d8..3fe647fb7 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -35,7 +35,7 @@ import { } from '../../loop/index'; import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; -import { abortable, userCancellationReason } from '../../utils/abort'; +import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; @@ -490,6 +490,17 @@ export class TurnFlow { if (this.currentId === turnId) { this.agent.usage.endTurn(); } + // A user interrupt (e.g. Esc) aborts the turn without the normal Stop hook + // firing, so external tooling that tracks status from hooks would otherwise + // never see the turn stop. Emit an observation-only Interrupt event for it. + // Gate on isUserCancellation: a `cancelled` turn can also come from a + // programmatic abort (e.g. a subagent deadline timeout, which shares this + // hook engine), and those must not be misreported as a user interrupt. + if (ended.reason === 'cancelled' && isUserCancellation(signal.reason)) { + void this.agent.hooks?.fireAndForgetTrigger('Interrupt', { + inputData: { turnId, reason: 'cancelled' }, + }); + } this.agent.emitEvent(ended); if (standalone && this.currentId === turnId) { this.activeTurn = null; diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts index c8c1d9566..cf05ca747 100644 --- a/packages/agent-core/src/session/hooks/types.ts +++ b/packages/agent-core/src/session/hooks/types.ts @@ -9,6 +9,7 @@ export const HOOK_EVENT_TYPES = [ 'UserPromptSubmit', 'Stop', 'StopFailure', + 'Interrupt', 'SessionStart', 'SessionEnd', 'SubagentStart', diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 8afa05ecc..0d3873fa8 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -16,6 +16,7 @@ import { import { describe, expect, it, vi } from 'vitest'; import { HookEngine } from '../../src/session/hooks'; +import { abortError } from '../../src/utils/abort'; import type { AgentOptions } from '../../src/agent'; import type { Logger, LogPayload } from '../../src/logging'; import type { @@ -808,6 +809,71 @@ describe('Agent turn flow', () => { expect(triggered).toEqual([['StopFailure', 'Error', 1]]); }); + it('fires Interrupt when the user cancels an active turn', async () => { + const triggered: Array<[string, string, number]> = []; + const hookEngine = new HookEngine( + [ + { + event: 'Interrupt', + command: 'exit 0', + }, + ], + { + onTriggered: (event, target, count) => { + triggered.push([event, target, count]); + }, + }, + ); + const ctx = testAgent({ + hookEngine, + kaos: createCommandKaos('should-not-run'), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + await ctx.rpc.cancel({ turnId: 0 }); + await ctx.untilTurnEnd(); + + expect(triggered).toEqual([['Interrupt', '', 1]]); + }); + + it('does not fire Interrupt for a non-user (programmatic) abort', async () => { + const triggered: Array<[string, string, number]> = []; + const hookEngine = new HookEngine( + [ + { + event: 'Interrupt', + command: 'exit 0', + }, + ], + { + onTriggered: (event, target, count) => { + triggered.push([event, target, count]); + }, + }, + ); + const ctx = testAgent({ + hookEngine, + kaos: createCommandKaos('should-not-run'), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + // A programmatic abort (e.g. a subagent deadline timeout) carries a plain + // AbortError as its reason, not a UserCancellationError, so it must not be + // reported as a user interrupt. + ctx.agent.turn.cancel(0, abortError()); + await ctx.untilTurnEnd(); + + expect(triggered).toEqual([]); + }); + it('resolves the latest request-scoped OAuth auth before each generation', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; From 856ec002906f4964086915ceb9aa616b89ab6594 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Wed, 10 Jun 2026 18:09:53 +0800 Subject: [PATCH 023/476] fix: preserve tool result images in chat completions (#626) --- .changeset/fix-tool-result-images.md | 6 ++ .../kosong/src/providers/openai-legacy.ts | 74 +++++++++++++++++- packages/kosong/test/openai-legacy.test.ts | 77 ++++++++++++++++++- 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-tool-result-images.md diff --git a/.changeset/fix-tool-result-images.md b/.changeset/fix-tool-result-images.md new file mode 100644 index 000000000..e38dcd1eb --- /dev/null +++ b/.changeset/fix-tool-result-images.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Preserve image outputs from tools when using OpenAI-compatible chat completions. diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 23820c8db..5fb313c22 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -50,6 +50,8 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, }; +const TOOL_RESULT_IMAGE_PROMPT = 'Attached image(s) from tool result:'; +const TOOL_RESULT_IMAGE_PLACEHOLDER = '(see attached image)'; function extractReasoningContent( source: unknown, @@ -165,7 +167,7 @@ function convertMessage( : toolMessageConversion; if (effectiveConversion !== null) { - result.content = convertToolMessageContent(message, effectiveConversion); + result.content = convertToolMessageContentForChat(message, effectiveConversion); } else { // Pure-text tool result with no conversion configured: serialize via the // generic content-part path so single-text messages become a plain string. @@ -217,6 +219,70 @@ function convertMessage( return result; } + +function convertToolMessageContentForChat( + message: Message, + conversion: ToolMessageConversion, +): string | OpenAIContentPart[] { + const content = convertToolMessageContent(message, conversion); + if ( + typeof content === 'string' && + content.length === 0 && + message.content.some((part) => part.type === 'image_url') + ) { + return TOOL_RESULT_IMAGE_PLACEHOLDER; + } + return content; +} + +function toolResultImageParts(message: Message): OpenAIContentPart[] { + const images: OpenAIContentPart[] = []; + for (const part of message.content) { + if (part.type !== 'image_url') continue; + images.push({ + type: 'image_url', + image_url: + part.imageUrl.id === undefined + ? { url: part.imageUrl.url } + : { url: part.imageUrl.url, id: part.imageUrl.id }, + }); + } + return images; +} + +function appendToolResultImagesMessage( + messages: OpenAIMessage[], + pendingToolResultImages: OpenAIContentPart[], +): void { + if (pendingToolResultImages.length === 0) return; + messages.push({ + role: 'user', + content: [{ type: 'text', text: TOOL_RESULT_IMAGE_PROMPT }, ...pendingToolResultImages], + }); + pendingToolResultImages.length = 0; +} + +function convertHistoryMessages( + history: readonly Message[], + reasoningKey: string | undefined, + toolMessageConversion: ToolMessageConversion, +): OpenAIMessage[] { + const messages: OpenAIMessage[] = []; + const pendingToolResultImages: OpenAIContentPart[] = []; + + for (const msg of history) { + if (msg.role !== 'tool') { + appendToolResultImagesMessage(messages, pendingToolResultImages); + } + messages.push(convertMessage(msg, reasoningKey, toolMessageConversion)); + if (msg.role === 'tool') { + pendingToolResultImages.push(...toolResultImageParts(msg)); + } + } + + appendToolResultImagesMessage(messages, pendingToolResultImages); + return messages; +} export class OpenAILegacyStreamedMessage implements StreamedMessage { private _id: string | null = null; private _usage: TokenUsage | null = null; @@ -437,9 +503,9 @@ export class OpenAILegacyChatProvider implements ChatProvider { history, OPENAI_CHAT_TOOL_CALL_ID_POLICY, ); - for (const msg of normalizedHistory) { - messages.push(convertMessage(msg, this._reasoningKey, this._toolMessageConversion)); - } + messages.push( + ...convertHistoryMessages(normalizedHistory, this._reasoningKey, this._toolMessageConversion), + ); const kwargs: Record = normalizeGenerationKwargs( this._model, diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 8847f6626..07aec0f01 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -287,7 +287,7 @@ describe('OpenAILegacyChatProvider', () => { ]); }); - it('tool call with image result flattens to text to satisfy API constraints', async () => { + it('tool call with image result keeps the tool result textual and reattaches images as user input', async () => { // OpenAI Chat Completions `tool` messages only accept text content. // Even when toolMessageConversion is unset, a tool result containing // image_url / audio_url / video_url parts must not be serialized as a @@ -319,15 +319,86 @@ describe('OpenAILegacyChatProvider', () => { ]; const body = await captureRequestBody(provider, '', [], history); - const toolMsg = (body['messages'] as Record[])[2]!; + const messages = body['messages'] as Record[]; + const toolMsg = messages[2]!; expect(toolMsg['role']).toBe('tool'); expect(toolMsg['tool_call_id']).toBe('call_abc123'); // Content must be a plain string, not a content-part array. expect(typeof toolMsg['content']).toBe('string'); // The text segment must survive; the image must not appear as a - // structured image_url part anywhere in the serialized content. + // structured image_url part inside the tool message. expect(toolMsg['content']).toContain('5'); expect(Array.isArray(toolMsg['content'])).toBe(false); + expect(messages[3]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + ], + }); + }); + + it('groups consecutive tool result images after all matching tool messages', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Fetch both images' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + toolCalls: [ + { type: 'function', id: 'call_first', name: 'first_image', arguments: '{}' }, + { type: 'function', id: 'call_second', name: 'second_image', arguments: '{}' }, + ], + }, + { + role: 'tool', + content: [ + { type: 'image_url', imageUrl: { url: 'https://example.com/first.png' } }, + ], + toolCallId: 'call_first', + toolCalls: [], + }, + { + role: 'tool', + content: [ + { type: 'text', text: 'second' }, + { type: 'image_url', imageUrl: { url: 'https://example.com/second.png' } }, + ], + toolCallId: 'call_second', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Fetch both images' }, + { + role: 'assistant', + content: 'ok', + tool_calls: [ + { + type: 'function', + id: 'call_first', + function: { name: 'first_image', arguments: '{}' }, + }, + { + type: 'function', + id: 'call_second', + function: { name: 'second_image', arguments: '{}' }, + }, + ], + }, + { role: 'tool', content: '(see attached image)', tool_call_id: 'call_first' }, + { role: 'tool', content: 'second', tool_call_id: 'call_second' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'image_url', image_url: { url: 'https://example.com/first.png' } }, + { type: 'image_url', image_url: { url: 'https://example.com/second.png' } }, + ], + }, + ]); }); it('parallel tool calls', async () => { From ecc049611508ca0e1b8ffbc8a2788b5ccc4c250e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:31:01 +0800 Subject: [PATCH 024/476] ci: release packages (#621) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/add-interrupt-hook.md | 5 ----- .changeset/fix-tool-result-images.md | 6 ------ apps/kimi-code/CHANGELOG.md | 10 ++++++++++ apps/kimi-code/package.json | 2 +- packages/kosong/CHANGELOG.md | 6 ++++++ packages/kosong/package.json | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) delete mode 100644 .changeset/add-interrupt-hook.md delete mode 100644 .changeset/fix-tool-result-images.md diff --git a/.changeset/add-interrupt-hook.md b/.changeset/add-interrupt-hook.md deleted file mode 100644 index 7ee37fb00..000000000 --- a/.changeset/add-interrupt-hook.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. diff --git a/.changeset/fix-tool-result-images.md b/.changeset/fix-tool-result-images.md deleted file mode 100644 index e38dcd1eb..000000000 --- a/.changeset/fix-tool-result-images.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Preserve image outputs from tools when using OpenAI-compatible chat completions. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 53e59fd9d..d09fac26c 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,15 @@ # @moonshot-ai/kimi-code +## 0.14.0 + +### Minor Changes + +- [#607](https://github.com/MoonshotAI/kimi-code/pull/607) [`b253a82`](https://github.com/MoonshotAI/kimi-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. + +### Patch Changes + +- [#626](https://github.com/MoonshotAI/kimi-code/pull/626) [`856ec00`](https://github.com/MoonshotAI/kimi-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. + ## 0.13.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index ab05311c4..5805045f0 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.13.1", + "version": "0.14.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index 11aa57f0d..d74265c38 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kosong +## 0.4.3 + +### Patch Changes + +- [#626](https://github.com/MoonshotAI/kimi-code/pull/626) [`856ec00`](https://github.com/MoonshotAI/kimi-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. + ## 0.4.2 ### Patch Changes diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 8b52d1eb1..b0716dc0c 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kosong", - "version": "0.4.2", + "version": "0.4.3", "private": true, "description": "The LLM abstraction layer for modern AI agent applications", "license": "MIT", From 0ee91066eaa8ec794c8337faefc14d1b1200ce82 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 10 Jun 2026 20:00:54 +0800 Subject: [PATCH 025/476] fix(acp-adapter): convert Unix paths to Windows separators for ACP file RPC (#628) - fix readTextFile and writeTextFile to use backslash separators on win32 - add changeset for acp-adapter and kimi-code --- .changeset/fix-windows-acp-paths.md | 6 +++++ packages/acp-adapter/src/kaos-acp.ts | 15 ++++++++--- packages/acp-adapter/test/kaos-acp.test.ts | 30 ++++++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-windows-acp-paths.md diff --git a/.changeset/fix-windows-acp-paths.md b/.changeset/fix-windows-acp-paths.md new file mode 100644 index 000000000..16d9f9aa8 --- /dev/null +++ b/.changeset/fix-windows-acp-paths.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/acp-adapter": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix ACP file reads and edits for Windows workspaces opened through IDE clients. diff --git a/packages/acp-adapter/src/kaos-acp.ts b/packages/acp-adapter/src/kaos-acp.ts index 14337d64d..70ed8614f 100644 --- a/packages/acp-adapter/src/kaos-acp.ts +++ b/packages/acp-adapter/src/kaos-acp.ts @@ -122,11 +122,12 @@ export class AcpKaos implements Kaos { path: string, _options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, ): Promise { + const rpcPath = this.toClientPath(path); try { - const resp = await this.conn.readTextFile({ sessionId: this.sessionId, path }); + const resp = await this.conn.readTextFile({ sessionId: this.sessionId, path: rpcPath }); return resp.content; } catch (err) { - throw wrapKaosError(`acp: readTextFile failed for ${path}`, err); + throw wrapKaosError(`acp: readTextFile failed for ${rpcPath}`, err); } } @@ -221,13 +222,19 @@ export class AcpKaos implements Kaos { } private async acpWrite(path: string, content: string): Promise { + const rpcPath = this.toClientPath(path); try { - await this.conn.writeTextFile({ sessionId: this.sessionId, path, content }); + await this.conn.writeTextFile({ sessionId: this.sessionId, path: rpcPath, content }); } catch (err) { - throw wrapKaosError(`acp: writeTextFile failed for ${path}`, err); + throw wrapKaosError(`acp: writeTextFile failed for ${rpcPath}`, err); } } + private toClientPath(path: string): string { + if (this.inner.pathClass() !== 'win32') return path; + return path.replaceAll('/', '\\'); + } + // ── process execution: delegate to inner ─────────────────────────── exec(...args: string[]): Promise { diff --git a/packages/acp-adapter/test/kaos-acp.test.ts b/packages/acp-adapter/test/kaos-acp.test.ts index b313f9063..4564f2c6d 100644 --- a/packages/acp-adapter/test/kaos-acp.test.ts +++ b/packages/acp-adapter/test/kaos-acp.test.ts @@ -82,7 +82,8 @@ interface MockInnerKaos extends Kaos { }; } -function makeMockInner(): MockInnerKaos { +function makeMockInner(opts?: { pathClass?: 'posix' | 'win32' }): MockInnerKaos { + const pathClass = opts?.pathClass ?? 'posix'; const spy = { pathClassCalls: 0, normpathCalls: [] as string[], @@ -107,7 +108,7 @@ function makeMockInner(): MockInnerKaos { osEnv: { os: 'linux', shell: 'bash' } as unknown as Environment, pathClass: () => { spy.pathClassCalls += 1; - return 'posix'; + return pathClass; }, normpath: (p: string) => { spy.normpathCalls.push(p); @@ -231,6 +232,31 @@ describe('AcpKaos', () => { expect((err as Error).message).toContain('rpc died'); } }); + + it('uses win32-native separators for ACP file RPC paths', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'HELLO' }), + }); + const inner = makeMockInner({ pathClass: 'win32' }); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + await kaos.readText('G:/python-code/render_with_mult_gpu/README.md'); + await kaos.writeText('G:/python-code/render_with_mult_gpu/README.md', 'updated'); + + expect(conn.readCalls).toEqual([ + { + sessionId: 's1', + path: 'G:\\python-code\\render_with_mult_gpu\\README.md', + }, + ]); + expect(conn.writeCalls).toEqual([ + { + sessionId: 's1', + path: 'G:\\python-code\\render_with_mult_gpu\\README.md', + content: 'updated', + }, + ]); + }); }); describe('readBytes', () => { From 7ec738c4a1de41b3a042cfb48700dfaf51e9de94 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 10 Jun 2026 20:02:47 +0800 Subject: [PATCH 026/476] fix(agent-core): suppress premature stream close on shell timeout or kill (#604) - Add terminating-state guard to skip ERR_STREAM_PREMATURE_CLOSE when a process is killed or times out - Ensure timeout/kill reason is reported instead of the internal stream error - Add changeset for agent-core and kimi-code --- .changeset/fix-bash-premature-close.md | 6 +++ .../src/tools/builtin/shell/bash.ts | 26 ++++++++-- packages/agent-core/test/tools/bash.test.ts | 48 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-bash-premature-close.md diff --git a/.changeset/fix-bash-premature-close.md b/.changeset/fix-bash-premature-close.md new file mode 100644 index 000000000..65cd6ca6b --- /dev/null +++ b/.changeset/fix-bash-premature-close.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix premature stream close errors when shell processes time out or are killed. diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 3c4ffafee..b6e0c6e86 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -309,10 +309,11 @@ export class BashTool implements BuiltinTool { try { const builder = new ToolResultBuilder(); + const isTerminating = (): boolean => timedOut || aborted || killed; const [, exitCode] = await Promise.all([ Promise.all([ - readStreamIntoBuilder(proc.stdout, builder), - readStreamIntoBuilder(proc.stderr, builder), + readStreamIntoBuilder(proc.stdout, builder, isTerminating), + readStreamIntoBuilder(proc.stderr, builder, isTerminating), ]), proc.wait(), ]); @@ -437,15 +438,30 @@ export class BashTool implements BuiltinTool { async function readStreamIntoBuilder( stream: Readable, builder: ToolResultBuilder, + suppressPrematureClose?: () => boolean, ): Promise { const decoder = new StringDecoder('utf8'); - for await (const chunk of stream) { - const buf: Buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); - builder.write(decoder.write(buf)); + try { + for await (const chunk of stream) { + const buf: Buffer = + typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); + builder.write(decoder.write(buf)); + } + } catch (error) { + if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) { + throw error; + } } builder.write(decoder.end()); } +function isPrematureCloseError(error: unknown): boolean { + return ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ERR_STREAM_PREMATURE_CLOSE' + ); +} + function shellQuote(s: string): string { return `'${s.replaceAll("'", "'\\''")}'`; } diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index c1ce2a6af..a345445cf 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -126,6 +126,29 @@ function processThatNeverExits(): KaosProcess { }; } +function processWithOpenStreamsThatExitOnKill(): KaosProcess { + let currentExitCode: number | null = null; + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: 127, + get exitCode(): number | null { + return currentExitCode; + }, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => { + currentExitCode = 143; + resolveWait(143); + }), + }; +} + function context(args: BashInput, signal = new AbortController().signal) { return { turnId: '0', toolCallId: 'call_bash', args, signal }; } @@ -760,6 +783,31 @@ describe('BashTool', () => { } }); + it('reports timeout instead of premature close when cleanup destroys open output streams', async () => { + vi.useFakeTimers(); + try { + const proc = processWithOpenStreamsThatExitOnKill(); + const tool = new BashTool( + createFakeKaos({ execWithEnv: vi.fn().mockResolvedValue(proc), osEnv: posixEnv }), + '/workspace', + ); + + const running = executeTool(tool, context({ command: 'sleep 2', timeout: 1 })); + await vi.advanceTimersByTimeAsync(1000); + const result = await running; + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(result).toMatchObject({ + isError: true, + brief: 'Killed by timeout (1s)', + }); + expect(result.output).toContain('Command killed by timeout (1s)'); + expect(result.output).not.toContain('Premature close'); + } finally { + vi.useRealTimers(); + } + }); + it('rejects empty-string commands at the schema layer', () => { expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false); }); From 89097aa02c4d89e046553e1b1f411249189bc229 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 20:54:26 +0800 Subject: [PATCH 027/476] docs: sync changelog 0.13.1 & 0.14 (#620) * docs(changelog): sync 0.13.1 from apps/kimi-code/CHANGELOG.md * docs(changelog): sync 0.14.0 from apps/kimi-code/CHANGELOG.md --- docs/en/release-notes/changelog.md | 26 ++++++++++++++++++++++++++ docs/zh/release-notes/changelog.md | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 52f005db8..0fab16aa1 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -2,6 +2,32 @@ This page documents the changes in each Kimi Code CLI release. +## 0.14.0 (2026-06-10) + +### Features + +- Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. + +### Bug Fixes + +- Preserve image outputs from tools when using OpenAI-compatible chat completions. + +## 0.13.1 (2026-06-10) + +### Bug Fixes + +- Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. +- Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. +- Fix goal marker text overflowing terminal width. + +### Polish + +- Add Claude Fable 5 support to the Anthropic provider. +- Add an interactive undo selector and clearer undo-limit messages. +- YOLO mode no longer asks before writing or editing files outside the working directory. +- Clarify active skill prompts so loaded skills are no longer represented as system reminders. +- Tighten file tool guidance to route incremental edits through Edit. + ## 0.13.0 (2026-06-10) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index b7b585db1..c41a9bc1b 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -2,6 +2,32 @@ 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.14.0(2026-06-10) + +### 新功能 + +- 新增 `Interrupt` hook 事件,当用户中断某一轮次时(例如按 Esc)触发,让 hooks 可以观察到轮次正在停止,而不再卡在 working 状态。 + +### 修复 + +- 在使用 OpenAI 兼容的 Chat Completions 时保留工具输出的图像。 + +## 0.13.1(2026-06-10) + +### 修复 + +- 阻止在活跃 turn 期间 fork 会话,并将 wire protocol 定义整合到共享的内部包中。 +- 修复 Kimi Datasource,使其在当前 Kimi Code 环境中使用匹配的 OAuth 凭证和服务端点。 +- 修复 goal 标记文本超出终端宽度的问题。 + +### 优化 + +- 在 Anthropic 供应商中新增对 Claude Fable 5 的支持。 +- 新增交互式 undo 选择器和更清晰的 undo 限制提示消息。 +- YOLO 模式在工作目录外写入或编辑文件时不再询问。 +- 优化活跃 skill 提示词,使已加载的 skills 不再被表示为系统提醒。 +- 收紧文件工具引导,使增量编辑通过 Edit 工具执行。 + ## 0.13.0(2026-06-10) ### 新功能 From fabc3b218f0fd681843e23728623e5bffd63d7fa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 21:15:41 +0800 Subject: [PATCH 028/476] docs: limit changelog TOC to version headings (#633) --- docs/en/release-notes/changelog.md | 4 ++++ docs/zh/release-notes/changelog.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 0fab16aa1..13131a4e7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -1,3 +1,7 @@ +--- +outline: 2 +--- + # Changelog This page documents the changes in each Kimi Code CLI release. diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index c41a9bc1b..818d34235 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -1,3 +1,7 @@ +--- +outline: 2 +--- + # 变更记录 本页记录 Kimi Code CLI 每个版本的变更内容。 From 296142544ec64e93c9083a51d3a53a83496d10cb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 10 Jun 2026 21:26:36 +0800 Subject: [PATCH 029/476] feat: alias search and two-line descriptions for slash command autocomplete (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: search slash command aliases in autocomplete Slash-command name completion now matches aliases and shows them in the label as `name (alias1, alias2)` when the match came from an alias. Argument completion resolves aliases too. The intercepted completion keeps the inner provider's conventions: argument hints stay in the suggestion description, and primary-name matches outrank alias matches on score ties. * feat: wrap autocomplete descriptions onto up to two lines Long slash command / skill descriptions used to be truncated to a single line in the completion menu. CustomEditor now swaps the slash menu's list for a WrappingSelectList that wraps descriptions to at most two lines, ellipsizing past the second; non-slash completion keeps pi-tui's single-line list. Plain-text truncations also strip the trailing ANSI reset that pi-tui's truncateToWidth appends — embedded inside the theme colouring it reset the rest of the row, so a selected row with a truncated name rendered its two description lines in different colours. * chore: make changeset wording user-facing * style: pass optional description directly instead of conditional spread Follows the AGENTS.md convention for optional object properties. --- .../autocomplete-two-line-descriptions.md | 5 + .../slash-command-alias-autocomplete.md | 5 + .../tui/components/editor/custom-editor.ts | 45 ++++- .../editor/file-mention-provider.ts | 107 ++++++++++- .../components/editor/wrapping-select-list.ts | 177 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 9 +- .../components/editor/custom-editor.test.ts | 49 +++++ .../editor/file-mention-provider.test.ts | 139 ++++++++++++++ .../editor/wrapping-select-list.test.ts | 141 ++++++++++++++ 9 files changed, 670 insertions(+), 7 deletions(-) create mode 100644 .changeset/autocomplete-two-line-descriptions.md create mode 100644 .changeset/slash-command-alias-autocomplete.md create mode 100644 apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts create mode 100644 apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts diff --git a/.changeset/autocomplete-two-line-descriptions.md b/.changeset/autocomplete-two-line-descriptions.md new file mode 100644 index 000000000..4a8d25fde --- /dev/null +++ b/.changeset/autocomplete-two-line-descriptions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. diff --git a/.changeset/slash-command-alias-autocomplete.md b/.changeset/slash-command-alias-autocomplete.md new file mode 100644 index 000000000..a009d72ef --- /dev/null +++ b/.changeset/slash-command-alias-autocomplete.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 43b12ce1d..ded2af648 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -2,11 +2,21 @@ * Custom editor extending pi-tui Editor with app-level keybindings. */ -import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; +import { + Editor, + isKeyRelease, + matchesKey, + Key, + SelectList, + type SelectItem, + type TUI, +} from '@earendil-works/pi-tui'; import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { WrappingSelectList } from './wrapping-select-list'; + // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -31,6 +41,17 @@ interface AutocompleteInternals { readonly autocompleteDebounceTimer?: ReturnType; } +interface AutocompleteListFactoryInternals { + createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList; +} + +// Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT +// (dist/components/editor.js); keep in sync when bumping pi-tui. +const SLASH_COMMAND_SELECT_LIST_LAYOUT = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +} as const; + /** * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol * is active AND caps_lock is on. In that state terminals emit, e.g., @@ -129,7 +150,27 @@ export class CustomEditor extends Editor { // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - super(tui, createEditorTheme(), { paddingX: 4 }); + const theme = createEditorTheme(); + super(tui, theme, { paddingX: 4 }); + + // pi-tui keeps `createAutocompleteList` private; shadow it with an + // instance property so slash command menus render descriptions wrapped + // to at most two lines. Non-slash completion (paths, @ mentions) keeps + // pi-tui's single-line list. + (this as unknown as AutocompleteListFactoryInternals).createAutocompleteList = ( + prefix, + items, + ) => { + if (prefix.startsWith('/')) { + return new WrappingSelectList( + items, + this.getAutocompleteMaxVisible(), + theme.selectList, + SLASH_COMMAND_SELECT_LIST_LAYOUT, + ); + } + return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); + }; } private expandPasteMarkerAtCursor(): boolean { diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index dda016a2f..fc9dc43be 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -3,6 +3,7 @@ import { basename, join } from 'node:path'; import { CombinedAutocompleteProvider, + fuzzyMatch, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, @@ -13,6 +14,10 @@ const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); const MAX_FALLBACK_SCAN = 2000; const MAX_FALLBACK_SUGGESTIONS = 50; +export interface SlashAutocompleteCommand extends SlashCommand { + readonly aliases?: readonly string[]; +} + interface FsMentionCandidate { readonly path: string; readonly isDirectory: boolean; @@ -31,11 +36,20 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; constructor( - slashCommands: SlashCommand[], + private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, ) { - this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + // Build an expanded list that includes alias entries so that + // inner's argument completion can find commands by alias too. + const expanded: SlashAutocompleteCommand[] = []; + for (const cmd of slashCommands) { + expanded.push(cmd); + for (const alias of cmd.aliases ?? []) { + expanded.push({ ...cmd, name: alias }); + } + } + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath); } async getSuggestions( @@ -74,6 +88,66 @@ export class FileMentionProvider implements AutocompleteProvider { } } + // Handle slash-command name completion ourselves so that aliases are + // searchable and visible in the label. + if (!options.force && textBeforeCursor.startsWith('/')) { + const spaceIndex = textBeforeCursor.indexOf(' '); + if (spaceIndex === -1) { + const tokens = textBeforeCursor + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + type SlashMatch = { + cmd: SlashAutocompleteCommand; + score: number; + viaAlias: boolean; + label: string; + }; + const matches: SlashMatch[] = []; + + for (const cmd of this.slashCommands) { + const nameScore = scoreTokens(tokens, cmd.name); + if (nameScore !== null) { + matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); + continue; + } + // Aliases only count when the primary name missed; the label then + // lists them so the user can see why the command matched. + const aliases = cmd.aliases ?? []; + let bestAliasScore: number | null = null; + for (const alias of aliases) { + const aliasScore = scoreTokens(tokens, alias); + if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { + bestAliasScore = aliasScore; + } + } + if (bestAliasScore !== null) { + matches.push({ + cmd, + score: bestAliasScore, + viaAlias: true, + label: `${cmd.name} (${aliases.join(', ')})`, + }); + } + } + + // Primary-name matches outrank alias matches on score ties. + matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + prefix: textBeforeCursor, + }; + } + } + try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { @@ -238,3 +312,32 @@ function shouldSuppressSlashArgumentCompletion( if (!textBeforeCursor.includes(' ')) return false; return textAfterCursor.trimStart().length > 0; } + +/** + * All tokens must fuzzy-match `text`; returns the summed score, or null when + * any token misses. An empty token list matches everything with score 0. + * Mirrors pi-tui fuzzyFilter's token semantics — keep in sync if it changes. + */ +function scoreTokens(tokens: readonly string[], text: string): number | null { + let score = 0; + for (const token of tokens) { + const m = fuzzyMatch(token, text); + if (!m.matches) return null; + score += m.score; + } + return score; +} + +/** + * Mirrors CombinedAutocompleteProvider's description rendering so the + * intercepted name completion keeps showing the argument hint. + */ +function formatSlashCommandDescription(cmd: SlashAutocompleteCommand): string | undefined { + const desc = cmd.description ?? ''; + const full = cmd.argumentHint + ? desc + ? `${cmd.argumentHint} — ${desc}` + : cmd.argumentHint + : desc; + return full || undefined; +} diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts new file mode 100644 index 000000000..d9d16936d --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -0,0 +1,177 @@ +import { + SelectList, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type SelectItem, + type SelectListLayoutOptions, + type SelectListTheme, +} from '@earendil-works/pi-tui'; + +// Mirror pi-tui's private select-list layout constants +// (dist/components/select-list.js); keep in sync when bumping pi-tui. +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const DESCRIPTION_MAX_LINES = 2; +const ELLIPSIS = '…'; +const ELLIPSIS_WIDTH = visibleWidth(ELLIPSIS); + +// truncateToWidth appends an ANSI reset whenever it actually truncates. +// Labels and descriptions here are plain text, and the reset would sit +// inside the theme's colour wrapping and reset the rest of the line (e.g. +// a selected row with a truncated name loses its colour after the name), +// so strip it. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const TRAILING_ANSI_RESET = /(?:\u001B\[0m)+$/; + +function truncatePlainToWidth(text: string, maxWidth: number): string { + return truncateToWidth(text, maxWidth, '').replace(TRAILING_ANSI_RESET, ''); +} + +interface SelectListInternals { + readonly filteredItems: SelectItem[]; + readonly selectedIndex: number; + readonly maxVisible: number; + readonly theme: SelectListTheme; + readonly layout: SelectListLayoutOptions; +} + +/** + * SelectList that wraps item descriptions onto up to two lines instead of + * truncating them to one. Long command / skill descriptions stay readable; + * anything past the second line is ellipsized. + * + * Only `render` is replaced — selection, filtering, and key handling stay in + * pi-tui. pi-tui keeps the row state private, so the renderer reads it + * through a cast, the same idiom CustomEditor uses for autocomplete + * internals. + */ +export class WrappingSelectList extends SelectList { + override render(width: number): string[] { + const { filteredItems, selectedIndex, maxVisible, theme } = this.internals(); + if (filteredItems.length === 0) { + return [theme.noMatch(' No matching commands')]; + } + + const primaryColumnWidth = this.primaryColumnWidth(); + const startIndex = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), filteredItems.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, filteredItems.length); + + const lines: string[] = []; + for (let i = startIndex; i < endIndex; i++) { + const item = filteredItems[i]; + if (!item) continue; + lines.push(...this.renderItemLines(item, i === selectedIndex, width, primaryColumnWidth)); + } + + if (startIndex > 0 || endIndex < filteredItems.length) { + const scrollText = ` (${selectedIndex + 1}/${filteredItems.length})`; + lines.push(theme.scrollInfo(truncatePlainToWidth(scrollText, width - 2))); + } + return lines; + } + + private renderItemLines( + item: SelectItem, + isSelected: boolean, + width: number, + primaryColumnWidth: number, + ): string[] { + const { theme } = this.internals(); + const prefix = isSelected ? '→ ' : ' '; + const prefixWidth = visibleWidth(prefix); + const description = item.description + ? item.description.replaceAll(/[\r\n]+/g, ' ').trim() + : undefined; + + if (description && width > 40) { + const effectivePrimaryColumnWidth = Math.max( + 1, + Math.min(primaryColumnWidth, width - prefixWidth - 4), + ); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimaryValue( + item, + isSelected, + maxPrimaryWidth, + effectivePrimaryColumnWidth, + ); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = ' '.repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety, as upstream + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const descriptionLines = wrapDescription(description, remainingWidth); + const indent = ' '.repeat(descriptionStart); + if (isSelected) { + return descriptionLines.map((line, index) => + theme.selectedText(index === 0 ? `${prefix}${truncatedValue}${spacing}${line}` : indent + line), + ); + } + return descriptionLines.map((line, index) => + index === 0 + ? prefix + truncatedValue + theme.description(spacing + line) + : theme.description(indent + line), + ); + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimaryValue(item, isSelected, maxWidth, maxWidth); + return [isSelected ? theme.selectedText(`${prefix}${truncatedValue}`) : prefix + truncatedValue]; + } + + private truncatePrimaryValue( + item: SelectItem, + isSelected: boolean, + maxWidth: number, + columnWidth: number, + ): string { + const { layout } = this.internals(); + const displayValue = item.label || item.value; + const truncated = layout.truncatePrimary + ? layout.truncatePrimary({ text: displayValue, maxWidth, columnWidth, item, isSelected }) + : displayValue; + return truncatePlainToWidth(truncated, maxWidth); + } + + private primaryColumnWidth(): number { + const { filteredItems, layout } = this.internals(); + const rawMin = + layout.minPrimaryColumnWidth ?? layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + layout.maxPrimaryColumnWidth ?? layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const min = Math.max(1, Math.min(rawMin, rawMax)); + const max = Math.max(1, Math.max(rawMin, rawMax)); + const widest = filteredItems.reduce( + (acc, item) => Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), + 0, + ); + return Math.max(min, Math.min(widest, max)); + } + + private internals(): SelectListInternals { + return this as unknown as SelectListInternals; + } +} + +/** + * Wrap `text` to at most DESCRIPTION_MAX_LINES lines of `width` columns. + * When the text needs more lines, the last visible line is rebuilt from the + * remaining text and ellipsized. + */ +function wrapDescription(text: string, width: number): string[] { + const wrapped = wrapTextWithAnsi(text, width); + if (wrapped.length <= DESCRIPTION_MAX_LINES) { + return wrapped; + } + const kept = wrapped.slice(0, DESCRIPTION_MAX_LINES - 1); + const rest = wrapped.slice(DESCRIPTION_MAX_LINES - 1).join(' '); + const clipped = truncatePlainToWidth(rest, width - ELLIPSIS_WIDTH).trimEnd(); + return [...kept, `${clipped}${ELLIPSIS}`]; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 71a43fa3b..bf44fc103 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -6,7 +6,6 @@ import { type Component, type Focusable, getCapabilities, - type SlashCommand, Spacer, } from '@earendil-works/pi-tui'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; @@ -64,7 +63,10 @@ import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; -import { FileMentionProvider } from './components/editor/file-mention-provider'; +import { + FileMentionProvider, + type SlashAutocompleteCommand, +} from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; @@ -310,10 +312,11 @@ export class KimiTUI { } private setupAutocomplete(): void { - const slashCommands: SlashCommand[] = this.getSlashCommands().map((cmd) => { + const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { const completer = cmd.completeArgs; return { name: cmd.name, + aliases: cmd.aliases, description: cmd.description, ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), ...(completer !== undefined diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 2f82aabea..fa30293cc 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -11,6 +11,7 @@ import { CustomEditor } from '#/tui/components/editor/custom-editor'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + terminal: { rows: 40, cols: 120 }, } as unknown as TUI; return new CustomEditor(tui); } @@ -72,6 +73,54 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); +describe('CustomEditor slash menu description wrapping', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => { + const editor = makeEditor(); + const description = 'word '.repeat(60).trim(); + editor.setAutocompleteProvider( + providerReturning([{ value: 'deep', label: 'deep', description }]), + ); + + editor.handleInput('/'); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('word')); + expect(descriptionLines).toHaveLength(2); + expect(descriptionLines[1]).toContain('…'); + }); + + it('keeps non-slash autocomplete descriptions on a single line', async () => { + const editor = makeEditor(); + const description = 'path '.repeat(60).trim(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ + items: [{ value: '@src/file.ts', label: 'file.ts', description }], + prefix: '@f', + })), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ + lines, + cursorLine, + cursorCol, + })), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + // @-mention requests are debounced (20ms), unlike slash menus. + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('path')); + expect(descriptionLines).toHaveLength(1); + expect(plain.join('\n')).not.toContain('…'); + }); +}); + describe('CustomEditor Kitty key release handling', () => { it('ignores Kitty key release events instead of inserting their CSI-u payload', () => { const editor = makeEditor(); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index ec0bea1cd..b898ec89c 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -25,6 +25,30 @@ const GOAL_COMMAND = { : null, }; +const NEW_COMMAND = { + name: 'new', + aliases: ['clear'], + description: 'Start a fresh session in the current workspace', +}; + +const LARK_CALENDAR_COMMAND = { + name: 'skill:lark-calendar', + aliases: [], + description: 'Manage Lark calendars', +}; + +const HELP_COMMAND = { + name: 'help', + aliases: ['h'], + description: 'Show help', +}; + +const HELP_FULL_COMMAND = { + name: 'help', + aliases: ['h', '?'], + description: 'Show help', +}; + describe('FileMentionProvider', () => { let workDir: string; @@ -58,6 +82,121 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('searches slash command aliases and displays aliases in the command label', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/clear'); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + }); + + it('prefers exact alias matches over fuzzy skill matches', async () => { + const provider = new FileMentionProvider( + [NEW_COMMAND, LARK_CALENDAR_COMMAND], + workDir, + NO_FD, + ); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + expect(result!.items[0]?.value).not.toBe('skill:lark-calendar'); + }); + + it('does not show aliases when the primary name already matches', async () => { + const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); + const line = '/h'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help', + }); + }); + + it('does not show aliases in labels when query is empty', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new', + }); + }); + + it('includes the argument hint in the description like the inner provider does', async () => { + const provider = new FileMentionProvider( + [{ name: 'goal', description: 'Start or manage a goal', argumentHint: '' }], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/go'], 0, 3, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'goal', + description: ' — Start or manage a goal', + }); + }); + + it('joins multiple aliases with an ASCII comma in the label', async () => { + const provider = new FileMentionProvider([HELP_FULL_COMMAND], workDir, NO_FD); + // '?' only matches the alias, not the primary name, so the label must + // list the aliases. + const result = await provider.getSuggestions(['/?'], 0, 2, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help (h, ?)', + }); + }); + + it('returns null for a bare slash when no commands are registered', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['/'], 0, 1, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('ranks primary-name matches above alias matches with equal scores', async () => { + const provider = new FileMentionProvider( + [ + { name: 'bar', aliases: ['foo'], description: 'Bar command' }, + { name: 'foo', aliases: [], description: 'Foo command' }, + ], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/foo'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]?.value).toBe('foo'); + expect(result!.items[1]).toMatchObject({ + value: 'bar', + label: 'bar (foo)', + }); + }); + it('does not turn leading-whitespace slash into root path completion', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl() }); diff --git a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts new file mode 100644 index 000000000..8a8a7ba00 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -0,0 +1,141 @@ +import { visibleWidth, type SelectItem, type SelectListTheme } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; + +/** Marker theme so assertions can see which style hook painted each part. */ +const MARKER_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => `[S]${s}`, + description: (s) => `[D]${s}`, + scrollInfo: (s) => `[I]${s}`, + noMatch: (s) => `[N]${s}`, +}; + +const IDENTITY_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => s, + description: (s) => s, + scrollInfo: (s) => s, + noMatch: (s) => s, +}; + +/** Mirrors pi-tui's slash command layout (editor.js). */ +const SLASH_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32 }; + +// With two 4-char labels and SLASH_LAYOUT at width 80, the primary column is +// 12 wide: prefix(2) + label(4) + spacing(8) puts descriptions at column 14 +// with 64 columns of room (80 - 14 - 2 safety). +const DESCRIPTION_INDENT = ' '.repeat(14); + +function makeList(items: SelectItem[], maxVisible = 5): WrappingSelectList { + return new WrappingSelectList(items, maxVisible, MARKER_THEME, SLASH_LAYOUT); +} + +describe('WrappingSelectList', () => { + it('renders short descriptions on a single line', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] Second command', + ]); + }); + + it('wraps a long description onto a second indented line without an ellipsis', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { + value: 'init', + label: 'init', + description: + 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt', + }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] lorem ipsum dolor sit amet consectetur adipiscing elit sed do', + `[D]${DESCRIPTION_INDENT}eiusmod tempor incididunt`, + ]); + }); + + it('caps descriptions at two lines and ellipsizes the overflow', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description }, + ]).render(80); + + expect(lines).toHaveLength(3); + expect(lines[1]).toMatch(/^ {2}init\[D\] {8}lorem ipsum/); + expect(lines[2]).toMatch(new RegExp(`^\\[D\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]!.endsWith('…')).toBe(true); + }); + + it('paints every line of the selected item with the selected style', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines[0]).toMatch(/^\[S\]→ goal {8}lorem ipsum/); + expect(lines[1]).toMatch(new RegExp(`^\\[S\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]).toBe(' init[D] Second command'); + }); + + it('falls back to primary-only single lines on narrow widths', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(40); + + expect(lines).toEqual(['[S]→ goal', ' init']); + }); + + it('keeps the scroll indicator when items overflow maxVisible', () => { + const items = Array.from({ length: 7 }, (_, i) => ({ + value: `cmd${i}`, + label: `cmd${i}`, + description: 'Short', + })); + const lines = makeList(items, 5).render(80); + + expect(lines).toHaveLength(6); + expect(lines[5]).toBe('[I] (1/7)'); + }); + + it('does not leak ANSI resets into themed lines when the primary name is truncated', () => { + const description = 'Use when about to claim work is complete fixed or passing before committing'; + const lines = makeList([ + { value: 'verify', label: 'skill:verification-before-completion', description }, + { value: 'init', label: 'skill:another-very-long-command-name', description }, + ]).render(80); + + // truncateToWidth appends [0m when it truncates; embedded inside the + // selected/description colouring it would reset the rest of the line. + for (const line of lines) { + expect(line).not.toContain('\u001B'); + } + }); + + it('never emits a line wider than the requested width, including CJK text', () => { + const list = new WrappingSelectList( + [ + { value: 'lark', label: 'skill:lark-calendar', description: '管理飞书日历的技能描述'.repeat(8) }, + { value: 'init', label: 'init', description: 'word '.repeat(60).trim() }, + ], + 5, + IDENTITY_THEME, + SLASH_LAYOUT, + ); + + for (const line of list.render(80)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(80); + } + }); +}); From 42a104a840ee8ae1a9a2fc0264e39b3ef9e410b1 Mon Sep 17 00:00:00 2001 From: Cyning12 <231127227@qq.com> Date: Wed, 10 Jun 2026 21:31:32 +0800 Subject: [PATCH 030/476] docs: align getting-started Node.js requirement with package engines (#622) Fixes misleading npm install prerequisite (24.15.0) so docs match apps/kimi-code package.json engines.node >=22.19.0. Co-authored-by: cyning Co-authored-by: Cursor Co-authored-by: liruifengv --- docs/en/guides/getting-started.md | 2 +- docs/zh/guides/getting-started.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index 9c7c52d6b..d2ee52a6b 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -40,7 +40,7 @@ The script automatically downloads the latest release, verifies the checksum, an ### npm installation -Requires Node.js 24.15.0 or later: +Requires Node.js 22.19.0 or later: ```sh node --version diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index ba32258cf..229bde7db 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -40,7 +40,7 @@ irm https://code.kimi.com/kimi-code/install.ps1 | iex ### npm 安装 -需要 Node.js 24.15.0 或更高版本: +需要 Node.js 22.19.0 或更高版本: ```sh node --version From d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Wed, 10 Jun 2026 21:39:03 +0800 Subject: [PATCH 031/476] fix: stop silently dropping unsupported multimodal content in kosong providers (#632) * fix: stop silently dropping unsupported media in provider conversions * fix: keep non-standard audio/video parts off the chat completions wire --- .changeset/fix-provider-media-placeholders.md | 6 + packages/kosong/src/providers/anthropic.ts | 23 ++- .../kosong/src/providers/openai-common.ts | 12 ++ .../kosong/src/providers/openai-legacy.ts | 59 ++++---- .../kosong/src/providers/openai-responses.ts | 99 ++++++++++--- packages/kosong/test/anthropic.test.ts | 67 +++++++++ packages/kosong/test/openai-legacy.test.ts | 71 +++++++++- packages/kosong/test/openai-responses.test.ts | 132 ++++++++++++++++-- 8 files changed, 408 insertions(+), 61 deletions(-) create mode 100644 .changeset/fix-provider-media-placeholders.md diff --git a/.changeset/fix-provider-media-placeholders.md b/.changeset/fix-provider-media-placeholders.md new file mode 100644 index 000000000..e506b28ac --- /dev/null +++ b/.changeset/fix-provider-media-placeholders.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 9bdbc15dd..f8a09239d 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -412,6 +412,15 @@ interface AnthropicImageBlock { cache_control?: { type: 'ephemeral' }; } +// The Messages API has no representation for audio or video input. Instead of +// silently dropping such parts (the model would not even know an attachment +// existed), emit a placeholder text block so it can acknowledge the gap. +// Consecutive parts of the same kind collapse into a single placeholder. +const OMITTED_MEDIA_PLACEHOLDER = { + audio_url: '(audio omitted: not supported by this provider)', + video_url: '(video omitted: not supported by this provider)', +} as const; + const SUPPORTED_B64_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); function imageUrlPartToAnthropic(url: string): AnthropicImageBlock { @@ -458,8 +467,13 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu } } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url)); + } else if (part.type === 'audio_url' || part.type === 'video_url') { + const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; + const last = blocks.at(-1); + if (!(last?.type === 'text' && last.text === placeholder)) { + blocks.push({ type: 'text', text: placeholder }); + } } - // Other types not supported by Anthropic in tool results } return { type: 'tool_result', @@ -522,8 +536,13 @@ function convertMessage(message: Message, model: string): MessageParam { } else if (part.think !== '' && shouldPreserveUnsignedThinking(model)) { blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } + } else if (part.type === 'audio_url' || part.type === 'video_url') { + const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; + const last = blocks.at(-1); + if (!(last?.type === 'text' && last.text === placeholder)) { + blocks.push({ type: 'text', text: placeholder } satisfies TextBlockParam); + } } - // audio_url, video_url: not supported by Anthropic, skip } // Tool calls -> ToolUseBlockParam diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index 10fb42726..8727ef5ed 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -286,6 +286,18 @@ export function normalizeOpenAIFinishReason(raw: string | null | undefined): { */ export type ToolMessageConversion = 'extract_text' | null; +/** + * Shared wording for tool-result media that cannot live inside the tool + * message itself and is reattached as a follow-up user message instead. + */ +export const TOOL_RESULT_MEDIA_PROMPT = 'Attached media from tool result:'; +export const TOOL_RESULT_MEDIA_PLACEHOLDER = '(see attached media)'; + +/** A content part that is neither plain text nor reasoning. */ +export function isMediaPart(part: ContentPart): boolean { + return part.type !== 'text' && part.type !== 'think'; +} + /** * Convert tool-role message content according to the chosen strategy. */ diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 5fb313c22..9bdca8bae 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -21,6 +21,8 @@ import { isFunctionToolCall, normalizeOpenAIFinishReason, type OpenAIContentPart, + TOOL_RESULT_MEDIA_PLACEHOLDER, + TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, reasoningEffortToThinkingEffort, thinkingEffortToReasoningEffort, @@ -50,8 +52,6 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, }; -const TOOL_RESULT_IMAGE_PROMPT = 'Attached image(s) from tool result:'; -const TOOL_RESULT_IMAGE_PLACEHOLDER = '(see attached image)'; function extractReasoningContent( source: unknown, @@ -220,46 +220,55 @@ function convertMessage( return result; } +// Chat Completions has no url-based audio/video content part (only base64 +// `input_audio`), so unlike images these cannot be reattached as user input. +// Note the omission inline in the tool message text instead. +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; +const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; + function convertToolMessageContentForChat( message: Message, conversion: ToolMessageConversion, ): string | OpenAIContentPart[] { const content = convertToolMessageContent(message, conversion); - if ( - typeof content === 'string' && - content.length === 0 && - message.content.some((part) => part.type === 'image_url') - ) { - return TOOL_RESULT_IMAGE_PLACEHOLDER; + if (typeof content !== 'string') { + return content; } - return content; + const lines: string[] = content.length > 0 ? [content] : []; + if (message.content.some((part) => part.type === 'audio_url')) { + lines.push(OMITTED_AUDIO_PLACEHOLDER); + } + if (message.content.some((part) => part.type === 'video_url')) { + lines.push(OMITTED_VIDEO_PLACEHOLDER); + } + if (lines.length === 0 && message.content.some((part) => part.type === 'image_url')) { + return TOOL_RESULT_MEDIA_PLACEHOLDER; + } + return lines.join('\n'); } function toolResultImageParts(message: Message): OpenAIContentPart[] { const images: OpenAIContentPart[] = []; for (const part of message.content) { if (part.type !== 'image_url') continue; - images.push({ - type: 'image_url', - image_url: - part.imageUrl.id === undefined - ? { url: part.imageUrl.url } - : { url: part.imageUrl.url, id: part.imageUrl.id }, - }); + const converted = convertContentPart(part); + if (converted !== null) { + images.push(converted); + } } return images; } -function appendToolResultImagesMessage( +function appendToolResultMediaMessage( messages: OpenAIMessage[], - pendingToolResultImages: OpenAIContentPart[], + pendingToolResultMedia: OpenAIContentPart[], ): void { - if (pendingToolResultImages.length === 0) return; + if (pendingToolResultMedia.length === 0) return; messages.push({ role: 'user', - content: [{ type: 'text', text: TOOL_RESULT_IMAGE_PROMPT }, ...pendingToolResultImages], + content: [{ type: 'text', text: TOOL_RESULT_MEDIA_PROMPT }, ...pendingToolResultMedia], }); - pendingToolResultImages.length = 0; + pendingToolResultMedia.length = 0; } function convertHistoryMessages( @@ -268,19 +277,19 @@ function convertHistoryMessages( toolMessageConversion: ToolMessageConversion, ): OpenAIMessage[] { const messages: OpenAIMessage[] = []; - const pendingToolResultImages: OpenAIContentPart[] = []; + const pendingToolResultMedia: OpenAIContentPart[] = []; for (const msg of history) { if (msg.role !== 'tool') { - appendToolResultImagesMessage(messages, pendingToolResultImages); + appendToolResultMediaMessage(messages, pendingToolResultMedia); } messages.push(convertMessage(msg, reasoningKey, toolMessageConversion)); if (msg.role === 'tool') { - pendingToolResultImages.push(...toolResultImageParts(msg)); + pendingToolResultMedia.push(...toolResultImageParts(msg)); } } - appendToolResultImagesMessage(messages, pendingToolResultImages); + appendToolResultMediaMessage(messages, pendingToolResultMedia); return messages; } export class OpenAILegacyStreamedMessage implements StreamedMessage { diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 1afa70696..b3804f025 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -25,6 +25,9 @@ import { } from './capability-registry'; import { convertOpenAIError, + isMediaPart, + TOOL_RESULT_MEDIA_PLACEHOLDER, + TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, reasoningEffortToThinkingEffort, thinkingEffortToReasoningEffort, @@ -363,6 +366,12 @@ interface ResponseToolParam { parameters: Record; strict: boolean; } +// The Responses API has no input type for video, and only mp3/wav audio can +// be inlined as input_file data. Degrade such parts to placeholder text so +// the model still learns an attachment existed instead of silently losing it. +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: unsupported audio format)'; +const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; + function contentPartsToInputItems(parts: ContentPart[]): unknown[] { const items: unknown[] = []; for (const part of parts) { @@ -381,14 +390,14 @@ function contentPartsToInputItems(parts: ContentPart[]): unknown[] { break; case 'audio_url': { const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - if (mapped !== null) { - items.push(mapped); - } + items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); break; } - case 'think': case 'video_url': - // think: handled separately. video_url: not supported by Responses API. + items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); + break; + case 'think': + // Handled separately as reasoning items. break; } } @@ -405,7 +414,7 @@ function contentPartsToOutputItems(parts: ContentPart[]): unknown[] { return items; } -function messageContentToFunctionOutputItems(content: ContentPart[]): string | unknown[] { +function messageContentToFunctionOutputItems(content: ContentPart[]): unknown[] { const items: unknown[] = []; for (const part of content) { switch (part.type) { @@ -424,15 +433,14 @@ function messageContentToFunctionOutputItems(content: ContentPart[]): string | u // branch here, audio returned by a tool would be dropped on the // next turn. const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - if (mapped !== null) { - items.push(mapped); - } + items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); break; } - case 'think': case 'video_url': - // think / video_url still intentionally skipped: the Responses - // API has no representation for them inside a function_call_output. + items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); + break; + case 'think': + // Handled separately as reasoning items. break; } } @@ -477,10 +485,20 @@ function convertMessage( // tool role -> function_call_output if (role === 'tool') { const callId = message.toolCallId ?? ''; - const output: string | unknown[] = - toolMessageConversion === 'extract_text' - ? extractText(message) - : messageContentToFunctionOutputItems(message.content); + let output: string | unknown[]; + if (toolMessageConversion === 'extract_text') { + // Plain-string output for backends that reject structured + // function_call_output. Media parts are reattached as a user message + // by `convertHistoryMessages`; when the result carries no text at + // all, point the model at that follow-up message. + const text = extractText(message); + output = + text.length === 0 && message.content.some(isMediaPart) + ? TOOL_RESULT_MEDIA_PLACEHOLDER + : text; + } else { + output = messageContentToFunctionOutputItems(message.content); + } return [ { call_id: callId, @@ -571,6 +589,49 @@ function convertTool(tool: Tool): ResponseToolParam { strict: false, }; } + +/** + * Convert the history, buffering tool-result media when `extract_text` + * flattens tool outputs to plain strings. The buffered media items are + * reattached as a single user message after each run of consecutive tool + * messages — mirroring the OpenAI Chat Completions provider. + */ +function convertHistoryMessages( + history: readonly Message[], + modelName: string, + toolMessageConversion: ToolMessageConversion, +): unknown[] { + const input: unknown[] = []; + const pendingToolResultMedia: unknown[] = []; + + const flushPendingMedia = (): void => { + if (pendingToolResultMedia.length === 0) return; + input.push({ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: TOOL_RESULT_MEDIA_PROMPT }, + ...pendingToolResultMedia, + ], + }); + pendingToolResultMedia.length = 0; + }; + + for (const msg of history) { + if (msg.role !== 'tool') { + flushPendingMedia(); + } + input.push(...convertMessage(msg, modelName, toolMessageConversion)); + if (msg.role === 'tool' && toolMessageConversion === 'extract_text') { + pendingToolResultMedia.push( + ...messageContentToFunctionOutputItems(msg.content.filter(isMediaPart)), + ); + } + } + + flushPendingMedia(); + return input; +} export class OpenAIResponsesStreamedMessage implements StreamedMessage { private _id: string | null = null; private _usage: TokenUsage | null = null; @@ -991,9 +1052,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { history, OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, ); - for (const msg of normalizedHistory) { - input.push(...convertMessage(msg, this._model, this._toolMessageConversion)); - } + input.push( + ...convertHistoryMessages(normalizedHistory, this._model, this._toolMessageConversion), + ); const kwargs: Record = { ...this._generationKwargs }; const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index fe6f2e29c..b9a10dc37 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -447,6 +447,73 @@ describe('AnthropicChatProvider', () => { }); }); + it('user audio/video parts degrade to placeholder text, consecutive same-kind collapse', async () => { + // The Messages API cannot carry audio or video. Dropping the parts + // silently would leave the model unaware an attachment ever existed, + // so each unsupported part degrades to a placeholder text block. + const provider = createProvider(); + const history: Message[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Listen and watch:' }, + { type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } }, + { type: 'audio_url', audioUrl: { url: 'https://example.com/b.mp3' } }, + { type: 'video_url', videoUrl: { url: 'https://example.com/c.mp4' } }, + ] satisfies ContentPart[], + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Record[]; + + expect(messages[0]?.['content']).toEqual([ + { type: 'text', text: 'Listen and watch:' }, + { type: 'text', text: '(audio omitted: not supported by this provider)' }, + { + type: 'text', + text: '(video omitted: not supported by this provider)', + cache_control: { type: 'ephemeral' }, + }, + ]); + }); + + it('tool result audio degrades to placeholder text inside tool_result', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Say hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_tts', name: 'tts', arguments: '{}' }], + }, + { + role: 'tool', + content: [ + { type: 'audio_url', audioUrl: { url: 'https://example.com/hi.mp3' } }, + ] satisfies ContentPart[], + toolCallId: 'call_tts', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Record[]; + + expect(messages[2]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_tts', + content: [ + { type: 'text', text: '(audio omitted: not supported by this provider)' }, + ], + cache_control: { type: 'ephemeral' }, + }, + ], + }); + }); + it('parallel tool calls and tool results (request body capture)', async () => { const provider = createProvider(); const tcAdd: ToolCall = { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 07aec0f01..8335a8228 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -332,12 +332,77 @@ describe('OpenAILegacyChatProvider', () => { expect(messages[3]).toEqual({ role: 'user', content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'text', text: 'Attached media from tool result:' }, { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, ], }); }); + it('tool call with audio result notes the omission inline without reattaching', async () => { + // Chat Completions has no url-based audio/video content part (only + // base64 input_audio), so unlike images these cannot be reattached as + // a user message — a standard OpenAI endpoint would reject the request + // with a 400. The tool message notes the omission inline instead. + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Say hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_tts', name: 'tts', arguments: '{}' }], + }, + { + role: 'tool', + content: [ + { type: 'audio_url', audioUrl: { url: 'https://example.com/hi.mp3' } }, + ] satisfies ContentPart[], + toolCallId: 'call_tts', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + const messages = body['messages'] as Record[]; + expect(messages[2]).toEqual({ + role: 'tool', + content: '(audio omitted: not supported by this provider)', + tool_call_id: 'call_tts', + }); + // No follow-up user message: audio_url is not a standard Chat + // Completions content part and must not reach the wire. + expect(messages).toHaveLength(3); + }); + + it('tool call with text and video result appends the omission note to the text', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Record it' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_rec', name: 'record', arguments: '{}' }], + }, + { + role: 'tool', + content: [ + { type: 'text', text: 'recorded 5s clip' }, + { type: 'video_url', videoUrl: { url: 'https://example.com/rec.mp4' } }, + ] satisfies ContentPart[], + toolCallId: 'call_rec', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + const messages = body['messages'] as Record[]; + expect(messages[2]).toEqual({ + role: 'tool', + content: 'recorded 5s clip\n(video omitted: not supported by this provider)', + tool_call_id: 'call_rec', + }); + expect(messages).toHaveLength(3); + }); + it('groups consecutive tool result images after all matching tool messages', async () => { const provider = createProvider(); const history: Message[] = [ @@ -388,12 +453,12 @@ describe('OpenAILegacyChatProvider', () => { }, ], }, - { role: 'tool', content: '(see attached image)', tool_call_id: 'call_first' }, + { role: 'tool', content: '(see attached media)', tool_call_id: 'call_first' }, { role: 'tool', content: 'second', tool_call_id: 'call_second' }, { role: 'user', content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'text', text: 'Attached media from tool result:' }, { type: 'image_url', image_url: { url: 'https://example.com/first.png' } }, { type: 'image_url', image_url: { url: 'https://example.com/second.png' } }, ], diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 3ce2fd414..3e95d9152 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -292,7 +292,7 @@ describe('OpenAIResponsesChatProvider', () => { ]); }); - it('user message with unsupported audio_url format drops the audio part silently', async () => { + it('user message with unsupported audio_url format degrades to placeholder text', async () => { const provider = createProvider(); const history: Message[] = [ { @@ -307,8 +307,12 @@ describe('OpenAIResponsesChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); const input = body['input'] as Array<{ content: unknown[] }>; - // Only the text part survives; the unsupported ogg audio is dropped. - expect(input[0]!.content).toEqual([{ type: 'input_text', text: 'Bare text' }]); + // The unsupported ogg audio degrades to a placeholder instead of + // silently vanishing, so the model knows an attachment existed. + expect(input[0]!.content).toEqual([ + { type: 'input_text', text: 'Bare text' }, + { type: 'input_text', text: '(audio omitted: unsupported audio format)' }, + ]); }); it('multiple consecutive ThinkParts with the same encrypted value aggregate into one reasoning item with multiple summaries', async () => { @@ -407,6 +411,102 @@ describe('OpenAIResponsesChatProvider', () => { }); }); + it('toolMessageConversion=extract_text reattaches tool result media as a user message', async () => { + // extract_text flattens function_call_output to a plain string for + // backends that reject structured output. Media must not vanish with + // the flattening: the image-only result gets a placeholder string and + // the media items are reattached as a follow-up user message after + // the run of consecutive tool messages. + const provider = new OpenAIResponsesChatProvider({ + model: 'gpt-4.1', + apiKey: 'test-key', + toolMessageConversion: 'extract_text', + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Q' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_shot', name: 'screenshot', arguments: '{}' }, + { type: 'function', id: 'call_read', name: 'read', arguments: '{}' }, + ], + }, + { + role: 'tool', + content: [ + { type: 'image_url', imageUrl: { url: 'https://example.com/shot.png' } }, + ], + toolCallId: 'call_shot', + toolCalls: [], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'file body' }], + toolCallId: 'call_read', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + const input = body['input'] as Array>; + expect(input).toEqual([ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Q' }] }, + { + type: 'function_call', + call_id: 'call_shot', + name: 'screenshot', + arguments: '{}', + }, + { type: 'function_call', call_id: 'call_read', name: 'read', arguments: '{}' }, + { + type: 'function_call_output', + call_id: 'call_shot', + output: '(see attached media)', + }, + { type: 'function_call_output', call_id: 'call_read', output: 'file body' }, + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Attached media from tool result:' }, + { type: 'input_image', image_url: 'https://example.com/shot.png' }, + ], + }, + ]); + }); + + it('video_url in tool result degrades to placeholder text in function_call_output', async () => { + const provider = createProvider(); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Record it' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_rec', name: 'record', arguments: '{}' }], + }, + { + role: 'tool', + content: [ + { type: 'video_url', videoUrl: { url: 'https://example.com/rec.mp4' } }, + ], + toolCallId: 'call_rec', + toolCalls: [], + }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + const input = body['input'] as Array>; + const fnOutput = input.find((item) => item['type'] === 'function_call_output'); + expect(fnOutput).toEqual({ + type: 'function_call_output', + call_id: 'call_rec', + output: [ + { type: 'input_text', text: '(video omitted: not supported by this provider)' }, + ], + }); + }); + it('parallel tool calls produce multiple function_call and function_call_output items', async () => { const provider = createProvider(); const history: Message[] = [ @@ -1120,7 +1220,7 @@ describe('OpenAIResponsesChatProvider', () => { expect(body['max_output_tokens']).toBe(512); }); - it('video_url in user content is silently skipped (Responses API has no video representation)', async () => { + it('video_url in user content degrades to placeholder text (no video input type)', async () => { const provider = createProvider(); const history: Message[] = [ { @@ -1134,12 +1234,14 @@ describe('OpenAIResponsesChatProvider', () => { ]; const body = await captureRequestBody(provider, '', [], history); - // Only the text part survives; video is dropped. const input = body['input'] as Array<{ content: unknown[] }>; - expect(input[0]!.content).toEqual([{ type: 'input_text', text: 'Watch this:' }]); + expect(input[0]!.content).toEqual([ + { type: 'input_text', text: 'Watch this:' }, + { type: 'input_text', text: '(video omitted: not supported by this provider)' }, + ]); }); - it('audio_url with unsupported scheme is silently dropped from user content', async () => { + it('audio_url with unsupported scheme degrades to placeholder text', async () => { const provider = createProvider(); const history: Message[] = [ { @@ -1154,11 +1256,14 @@ describe('OpenAIResponsesChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); const input = body['input'] as Array<{ content: unknown[] }>; - // file:// URL is unsupported → drop - expect(input[0]!.content).toEqual([{ type: 'input_text', text: 'Hear:' }]); + // file:// URL cannot be encoded as input_file → placeholder + expect(input[0]!.content).toEqual([ + { type: 'input_text', text: 'Hear:' }, + { type: 'input_text', text: '(audio omitted: unsupported audio format)' }, + ]); }); - it('audio_url data URL with unknown subtype is silently dropped', async () => { + it('audio_url data URL with unknown subtype degrades to placeholder text', async () => { const provider = createProvider(); const history: Message[] = [ { @@ -1173,8 +1278,11 @@ describe('OpenAIResponsesChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); const input = body['input'] as Array<{ content: unknown[] }>; - // ogg subtype is not mp3/wav → drop - expect(input[0]!.content).toEqual([{ type: 'input_text', text: 'OGG:' }]); + // ogg subtype is not mp3/wav → placeholder + expect(input[0]!.content).toEqual([ + { type: 'input_text', text: 'OGG:' }, + { type: 'input_text', text: '(audio omitted: unsupported audio format)' }, + ]); }); }); From 71f5926d0e5868b502c29c22de17c05d758cc040 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 10 Jun 2026 22:29:51 +0800 Subject: [PATCH 032/476] feat(datasource): add yuandian_law legal data source + request-id trace (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register the yuandian_law (元典法律数据库) data source for Chinese laws/regulations and judicial case search. - Append a request-id / tool-call-id trace line to every tool result so failures can be correlated with backend logs. - Fix the documented MCP tool names in SKILL.md (-data -> _data). - Also includes the dev marketplace-server env isolation fix in dev.mjs. Co-authored-by: qer --- .changeset/README.md | 2 + apps/kimi-code/scripts/dev.mjs | 19 +++- .../test/utils/kimi-datasource-plugin.test.ts | 93 +++++++++++++++++++ docs/en/customization/plugins.md | 5 +- docs/zh/customization/plugins.md | 5 +- plugins/marketplace.json | 2 +- plugins/official/kimi-datasource/CHANGELOG.md | 5 + plugins/official/kimi-datasource/SKILL.md | 19 ++-- .../kimi-datasource/bin/kimi-datasource.mjs | 40 ++++++-- .../official/kimi-datasource/kimi.plugin.json | 8 +- 10 files changed, 173 insertions(+), 25 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index aed0865b2..9dfaa2c8e 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -39,6 +39,7 @@ Example scenarios: | SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` | | Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` | | Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed | +| Bundled official plugin change under `plugins/` (e.g. `kimi-datasource`) | No changeset — the plugin is versioned via its own `kimi.plugin.json` / `plugins/marketplace.json` and shipped through the marketplace CDN, not the npm package | ## Prerequisite: NPM Trusted Publishing (OIDC) @@ -138,6 +139,7 @@ The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, buil ## Notes - Every PR that affects publishable-package behavior or public API should include a corresponding changeset. +- Changes under `plugins/` (the bundled official plugins such as `kimi-datasource`) do **not** need a changeset: each plugin carries its own version in `kimi.plugin.json` and `plugins/marketplace.json` and is distributed via the marketplace CDN, separately from the `@moonshot-ai/kimi-code` npm package. - Changeset files must be committed to the repository — release PRs are only triggered after they're merged. - Release PRs require human review and merge; they will not publish automatically. - Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`. diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 0e6be4cf2..3cc2d0a58 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -9,15 +9,32 @@ import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mj const require = createRequire(import.meta.url); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const APP_ROOT = resolve(SCRIPT_DIR, '..'); +// Runtime variable the CLI reads to locate the marketplace JSON. const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// Opt-in for dev: point this run at an external marketplace instead of a local one. +const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL'; let marketplaceServer; const env = { ...process.env }; -if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) { +const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim(); +if (externalUrl !== undefined && externalUrl.length > 0) { + // Explicitly asked to use an external marketplace; don't start a local server. + env[MARKETPLACE_ENV] = externalUrl; + console.error(`Using external plugin marketplace: ${externalUrl}`); +} else { + // Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a + // random port, so multiple concurrent dev instances never collide. Overwrite any + // inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run. + const inherited = process.env[MARKETPLACE_ENV]?.trim(); marketplaceServer = await startPluginMarketplaceServer(); env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl; console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); + if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) { + console.error( + `(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`, + ); + } } const tsxCli = require.resolve('tsx/cli'); diff --git a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts index 13f9d38d0..51b6d04a2 100644 --- a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts +++ b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts @@ -216,6 +216,99 @@ describe('kimi-datasource MCP server', () => { await rm(tempDir, { recursive: true, force: true }); } }); + + it('registers yuandian_law in the get_data_source_desc enum', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, KIMI_CODE_HOME: kimiHome }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/list', {}); + + const tools = ( + result.result as { + tools: Array<{ name: string; inputSchema: { properties: { name: { enum: string[] } } } }>; + } + ).tools; + const desc = tools.find((tool) => tool.name === 'get_data_source_desc'); + expect(desc?.inputSchema.properties.name.enum).toContain('yuandian_law'); + } finally { + child?.stdin.end(); + child?.kill(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('appends a request-id / tool-call-id trace line to tool results', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + request.on('data', () => {}); + request.on('end', () => { + response.setHeader('x-request-id', 'backend-req-test'); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ is_success: true, result: { assistant: [{ type: 'text', text: 'ok' }] } }), + ); + }); + }); + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { name: 'yuandian_law' }, + }); + + const text = (result.result as { content: Array<{ text: string }> }).content[0]!.text; + expect(text).toContain('[kimi-datasource] request-id: backend-req-test · tool-call-id:'); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); }); // Pin the expected credential file name to the canonical OAuth-key resolver so diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index d2d71d4b5..ef43f354c 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -55,7 +55,7 @@ Network requests only go through `github.com` redirects and `codeload.github.com ## Kimi Datasource -Kimi Datasource is the official Kimi Code data plugin. It lets you query financial market data, macroeconomic indicators, corporate registration records, and academic literature in natural language — no manual API calls or data account registration required. +Kimi Datasource is the official Kimi Code data plugin. It lets you query financial market data, macroeconomic indicators, corporate registration records, academic literature, and Chinese laws and regulations in natural language — no manual API calls or data account registration required. ### Installation @@ -79,6 +79,8 @@ Once installed, describe your need in natural language and Kimi Code will automa **Literature review acceleration**: Tracing the research arc of RLHF? Get the most-cited papers, key authors, and core findings in seconds, so your literature review outline takes shape in half the time. +**On-the-spot legal lookup**: Stuck on which statute governs a residence-right contract dispute? Pinpoint the relevant Civil Code articles — full text, authority level, and validity — then pull a few comparable precedents to back them up, without digging through statute databases. + ### Coverage | Category | Scope | @@ -87,6 +89,7 @@ Once installed, describe your need in natural language and Kimi Code will automa | Macroeconomic data | World Bank data for 189 countries, 50+ years of time series (GDP, trade, population, climate, and more) | | Corporate data | Business registration, equity chain, legal risk, and related-entity graph for mainland Chinese companies | | Academic literature | Millions of papers across physics, mathematics, CS, quantitative finance, economics — including preprints | +| Legal | Chinese laws, regulations, and judicial cases — semantic/keyword search and detail lookup for statutes across all authority levels (constitution, laws, judicial interpretations, departmental rules), plus ordinary and authoritative case search | ### Notes diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index f851ea8fe..46fe39c08 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -55,7 +55,7 @@ Plugin 管理器会展示每个安装的来源和信任徽章:`kimi-official` ## Kimi Datasource -Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直接查询金融行情、宏观经济、企业工商和学术文献,无需手动调用接口或申请任何数据账号。 +Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直接查询金融行情、宏观经济、企业工商、学术文献和中国法律法规,无需手动调用接口或申请任何数据账号。 ### 安装 @@ -79,6 +79,8 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 **文献综述加速**:写论文要梳理 RLHF 领域的研究脉络?直接列出高引论文、主要作者和核心结论,综述提纲半小时内成型。 +**法律条文速查**:碰上居住权的合同纠纷,拿不准法条?一句话定位《民法典》相关条文原文、效力级别和时效性,再顺手拉几个相近判例佐证,不用翻法规库。 + ### 数据覆盖 | 类别 | 覆盖范围 | @@ -87,6 +89,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 | 宏观经济 | 世界银行 189 个成员国、50 年以上历史时间序列(GDP、贸易、人口、气候等) | | 企业数据 | 中国大陆境内企业工商信息、股权穿透、司法风险、关联图谱 | | 学术文献 | 物理、数学、计算机、金融、经济等领域百万量级论文,支持预印本查询 | +| 法律法规 | 中国法律法规与司法案例:宪法、法律、司法解释、部门规章等各效力层次的法规语义/关键词检索与详情,普通及权威判例检索 | ### 注意事项 diff --git a/plugins/marketplace.json b/plugins/marketplace.json index baa5da7ad..70544f9d4 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -5,7 +5,7 @@ "id": "kimi-datasource", "tier": "official", "displayName": "Kimi Datasource", - "version": "3.1.2", + "version": "3.2.0", "description": "Official datasource workflows.", "keywords": ["data", "mcp"], "source": "./official/kimi-datasource" diff --git a/plugins/official/kimi-datasource/CHANGELOG.md b/plugins/official/kimi-datasource/CHANGELOG.md index 9c5280f25..e6918fd0b 100644 --- a/plugins/official/kimi-datasource/CHANGELOG.md +++ b/plugins/official/kimi-datasource/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 3.2.0 - 2026-06-10 + +- Add the `yuandian_law` data source (元典法律数据库) for Chinese laws/regulations and judicial case search. +- Append a trace line (`request-id` / `tool-call-id`) to every tool result so failures can be correlated with backend logs. + ## 3.1.2 - 2026-06-09 - Use OAuth credentials and datasource endpoints that match the active Kimi Code environment. diff --git a/plugins/official/kimi-datasource/SKILL.md b/plugins/official/kimi-datasource/SKILL.md index 21b91b6ec..d9b39e621 100644 --- a/plugins/official/kimi-datasource/SKILL.md +++ b/plugins/official/kimi-datasource/SKILL.md @@ -1,8 +1,8 @@ --- name: kimi-datasource description: | - Universal data-source assistant. Use this skill when the user wants external structured data such as stocks, financial reports, technical indicators, A-share/HK/US markets, global macroeconomics, Chinese enterprise registry information, arXiv papers, or Google Scholar results. - This plugin exposes tools via MCP server `plugin-kimi-datasource-data`; call them in the flow `mcp__plugin-kimi-datasource-data__get_data_source_desc` → `mcp__plugin-kimi-datasource-data__call_data_source_tool`. + Universal data-source assistant. Use this skill when the user wants external structured data such as stocks, financial reports, technical indicators, A-share/HK/US markets, global macroeconomics, Chinese enterprise registry information, arXiv papers, Google Scholar results, or Chinese laws/regulations and judicial cases. + This plugin exposes tools via MCP server `plugin-kimi-datasource_data`; call them in the flow `mcp__plugin-kimi-datasource_data__get_data_source_desc` → `mcp__plugin-kimi-datasource_data__call_data_source_tool`. --- # kimi-datasource — 通用数据源助手 @@ -11,8 +11,8 @@ description: | 本 skill 使用 datasource MCP server 注册的两个工具,不要通过 Bash 手动执行脚本: -- `mcp__plugin-kimi-datasource-data__get_data_source_desc` -- `mcp__plugin-kimi-datasource-data__call_data_source_tool` +- `mcp__plugin-kimi-datasource_data__get_data_source_desc` +- `mcp__plugin-kimi-datasource_data__call_data_source_tool` 这两个工具由 Kimi Code 托管执行,参数直接按 tool schema 传 JSON。 @@ -20,7 +20,7 @@ description: | ## 1. 这个 skill 提供什么能力 -本 plugin 后面挂了 6 个外部数据源。每一行的"数据源名"就是传给 `get_data_source_desc` 的 `name`。 +本 plugin 后面挂了 7 个外部数据源。每一行的"数据源名"就是传给 `get_data_source_desc` 的 `name`。 | 能力域 | 数据源名 | 典型问题 | |---|---|---| @@ -30,6 +30,7 @@ description: | | **中国企业工商信息** | `tianyancha` | "字节跳动股东"、"比亚迪司法风险"、"宁德时代专利" | | **arXiv 论文预印本** | `arxiv` | "找 RAG 综述"、"下载 2406.xxxxx" | | **Google Scholar 学术搜索** | `scholar` | "Hinton 最新论文"、"transformer 综述高引文献" | +| **中国法律法规 / 司法案例** | `yuandian_law` | "民法典关于居住权的规定"、"帮我查劳动合同解除的相关法条"、"找几个不当得利的判例" | **不支持的能力**:通用 Web 搜索 / 实时新闻。问到这类问题,告诉用户当前数据源不覆盖。 @@ -51,16 +52,16 @@ description: | ### 例 1:用户问"茅台最近一年走势" 1. 股票走势 → `stock_finance_data` -2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"stock_finance_data"}` +2. 调用 `mcp__plugin-kimi-datasource_data__get_data_source_desc`,参数 `{"name":"stock_finance_data"}` 3. 从文档里找到"获取历史价格"那个 API,看它要 `ticker / start_date / end_date / file_path` 等 4. 用 web_search 核对 → 茅台 = `600519.SH` -5. 调用 `mcp__plugin-kimi-datasource-data__call_data_source_tool`,参数形如 `{"data_source_name":"stock_finance_data","api_name":"<文档里写的 api>","params":{"ticker":"600519.SH","start_date":"...","end_date":"...","file_path":"/tmp/mao_1y.csv"}}` +5. 调用 `mcp__plugin-kimi-datasource_data__call_data_source_tool`,参数形如 `{"data_source_name":"stock_finance_data","api_name":"<文档里写的 api>","params":{"ticker":"600519.SH","start_date":"...","end_date":"...","file_path":"/tmp/mao_1y.csv"}}` ### 例 2:用户问"找几篇 retrieval augmented generation 的综述" 1. 论文搜索 → `arxiv`(或 `scholar`,arxiv 更适合预印本,scholar 引用更全) -2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"arxiv"}` +2. 调用 `mcp__plugin-kimi-datasource_data__get_data_source_desc`,参数 `{"name":"arxiv"}` 3. 从文档里找到搜索类 API,看它要 `query / file_path / max_results` 等 4. 执行 `call_data_source_tool` @@ -68,7 +69,7 @@ description: | ### 例 3:用户问"字节跳动有哪些股东" 1. 企业工商 → `tianyancha` -2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"tianyancha"}` +2. 调用 `mcp__plugin-kimi-datasource_data__get_data_source_desc`,参数 `{"name":"tianyancha"}` 3. 注意:tianyancha 的 API 是动态注册的,文档会指引你**先用搜索类接口找到合适的 API 名,再调用** 4. **必须使用企业全称**("北京字节跳动科技有限公司"),不要用简称。不知道全称就先用 tianyancha 文档里的"公司搜索"接口查 diff --git a/plugins/official/kimi-datasource/bin/kimi-datasource.mjs b/plugins/official/kimi-datasource/bin/kimi-datasource.mjs index 5f44ef92a..f8ad0087b 100755 --- a/plugins/official/kimi-datasource/bin/kimi-datasource.mjs +++ b/plugins/official/kimi-datasource/bin/kimi-datasource.mjs @@ -18,7 +18,7 @@ import { arch, homedir, hostname, release, type } from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; -const VERSION = '3.1.2'; +const VERSION = '3.2.0'; const DEFAULT_KIMI_CODE_OAUTH_HOST = 'https://auth.kimi.com'; const DEFAULT_KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; const API_URL = datasourceApiUrl(); @@ -65,6 +65,7 @@ const TOOLS = [ 'tianyancha', 'arxiv', 'scholar', + 'yuandian_law', ], description: 'Data source name.', }, @@ -123,17 +124,18 @@ async function runTool(params) { isError: true, }; } + const trace = {}; try { const built = handler.buildParams(args); - const response = await callKimiTool(handler.method, built); + const response = await callKimiTool(handler.method, built, trace); const fileWarnings = await writeResponseFiles(response, expectedResponseFilePath(built)); const text = extractText(response); const formatted = (handler.format?.(text, built) ?? text).trim(); - return { content: [{ type: 'text', text: appendWarnings(formatted, fileWarnings) }] }; + return { content: [{ type: 'text', text: appendTrace(appendWarnings(formatted, fileWarnings), trace) }] }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { - content: [{ type: 'text', text: message }], + content: [{ type: 'text', text: appendTrace(message, trace) }], isError: true, }; } @@ -206,6 +208,25 @@ function appendWarnings(text, warnings) { return `${text}\n\n${warnings.join('\n')}`; } +// Pick the backend request id from the response headers, if the gateway sends one. +function extractRequestId(headers) { + for (const key of ['x-request-id', 'x-trace-id', 'x-msh-request-id', 'x-msh-trace-id', 'request-id']) { + const value = headers.get(key); + if (typeof value === 'string' && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +// Append a trace line so failures can be correlated with backend logs. The +// tool-call-id is the `X-Msh-Tool-Call-Id` header we send on every request. +function appendTrace(text, trace) { + if (trace === undefined || trace.toolCallId === undefined) return text; + const parts = []; + if (trace.requestId !== undefined) parts.push(`request-id: ${trace.requestId}`); + parts.push(`tool-call-id: ${trace.toolCallId}`); + return `${text}\n\n[kimi-datasource] ${parts.join(' · ')}`; +} + function resolveKimiHome() { const explicit = process.env.KIMI_CODE_HOME?.trim(); return explicit && explicit.length > 0 ? explicit : path.join(homedir(), '.kimi-code'); @@ -287,8 +308,10 @@ async function loadAccessToken() { return { kimiHome, token }; } -async function callKimiTool(method, params) { +async function callKimiTool(method, params, trace = {}) { const { kimiHome, token } = await loadAccessToken(); + const toolCallId = randomUUID(); + trace.toolCallId = toolCallId; const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); @@ -296,10 +319,11 @@ async function callKimiTool(method, params) { try { const response = await fetch(API_URL, { method: 'POST', - headers: await buildHeaders(kimiHome, token), + headers: await buildHeaders(kimiHome, token, toolCallId), body: JSON.stringify({ method, params }), signal: controller.signal, }); + trace.requestId = extractRequestId(response.headers); const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} error: ${text}`); @@ -319,11 +343,11 @@ async function callKimiTool(method, params) { } } -async function buildHeaders(kimiHome, token) { +async function buildHeaders(kimiHome, token, toolCallId) { return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'X-Msh-Tool-Call-Id': randomUUID(), + 'X-Msh-Tool-Call-Id': toolCallId, 'X-Msh-Platform': asciiHeader(process.env.KIMI_MSH_PLATFORM ?? 'kimi-code-cli'), 'X-Msh-Version': asciiHeader(process.env.KIMI_MSH_VERSION ?? VERSION), 'X-Msh-Device-Name': asciiHeader(process.env.KIMI_MSH_DEVICE_NAME ?? hostname()), diff --git a/plugins/official/kimi-datasource/kimi.plugin.json b/plugins/official/kimi-datasource/kimi.plugin.json index 3f43faba0..8ba677de7 100644 --- a/plugins/official/kimi-datasource/kimi.plugin.json +++ b/plugins/official/kimi-datasource/kimi.plugin.json @@ -1,8 +1,8 @@ { "name": "kimi-datasource", - "version": "3.1.2", - "description": "Finance, macro, enterprise, and academic data tools for Kimi Code.", - "keywords": ["finance", "data-source", "mcp"], + "version": "3.2.0", + "description": "Finance, macro, enterprise, academic, and legal data tools for Kimi Code.", + "keywords": ["finance", "data-source", "mcp", "legal"], "mcpServers": { "data": { "command": "node", @@ -12,7 +12,7 @@ }, "interface": { "displayName": "Kimi Datasource", - "shortDescription": "Finance, macro, enterprise, and academic data tools", + "shortDescription": "Finance, macro, enterprise, academic, and legal data tools", "developerName": "Moonshot AI" } } From 30459af6abc8308e7f13822d9dbef3a5be80dd4a Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Thu, 11 Jun 2026 11:35:34 +0800 Subject: [PATCH 033/476] fix: clean up background tasks on session exit (#641) * fix: clean up background tasks on session exit * Stop background tasks on session close Change versioning for agent-core and kimi-code to patch. Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn> --------- Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn> --- .changeset/stop-background-tasks-on-exit.md | 6 +++++ docs/en/configuration/config-files.md | 2 +- docs/en/configuration/env-vars.md | 2 +- docs/zh/configuration/config-files.md | 2 +- docs/zh/configuration/env-vars.md | 2 +- packages/agent-core/src/session/index.ts | 2 +- .../test/session/lifecycle-hooks.test.ts | 25 +++++++++++++++++-- 7 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 .changeset/stop-background-tasks-on-exit.md diff --git a/.changeset/stop-background-tasks-on-exit.md b/.changeset/stop-background-tasks-on-exit.md new file mode 100644 index 000000000..b88111c97 --- /dev/null +++ b/.changeset/stop-background-tasks-on-exit.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Stop background tasks by default when sessions close. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 7cf622736..0d64f5044 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -169,7 +169,7 @@ You can also switch models temporarily without touching the config file — by s | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently | -| `keep_alive_on_exit` | `boolean` | `true` | Whether to keep still-running background tasks when the session closes. Set to `false` to request that all background tasks stop before the process exits | +| `keep_alive_on_exit` | `boolean` | `false` | Whether to keep still-running background tasks when the session closes. By default, Kimi Code requests that all background tasks stop before the process exits; set this to `true` only when you want tasks to outlive the session | `keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, which takes higher priority than `config.toml`. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 50acb9121..77b7bdfb2 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -123,7 +123,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | Variable | Purpose | Valid values | | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | -| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path | | `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 885840245..e0a215f56 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -169,7 +169,7 @@ max_context_size = 1047576 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 | -| `keep_alive_on_exit` | `boolean` | `true` | 会话关闭时是否保留仍在运行的后台任务。设为 `false` 时,进程退出前会请求停止所有后台任务 | +| `keep_alive_on_exit` | `boolean` | `false` | 会话关闭时是否保留仍在运行的后台任务。默认情况下,Kimi Code 会在进程退出前请求停止所有后台任务;只有希望任务在会话结束后继续运行时才设为 `true` | `keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,优先级高于配置文件。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 2f95c93ae..e87eb146a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -123,7 +123,7 @@ kimi | 环境变量 | 用途 | 合法值 | | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | -| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml` | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 替换 `/plugins` 加载的 marketplace JSON | URL 或本地路径 | | `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能;`micro_compaction` 已默认开启 | `1`、`true`、`yes`、`on` | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 [`[experimental].micro_compaction`](./config-files.md#experimental) | 真值或假值 | diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 0c1950bb3..99efe086b 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -264,7 +264,7 @@ export class Session { env: process.env, envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, configValue: this.options.background?.keepAliveOnExit, - defaultValue: true, + defaultValue: false, parseEnv: parseBooleanEnv, }); if (keepAliveOnExit) return; diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index 7422272e7..aa63eb4c4 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -110,7 +110,7 @@ describe('Session lifecycle hooks', () => { await expect(session.close()).resolves.toBeUndefined(); }); - it('stops background tasks on close when keepAliveOnExit is false', async () => { + it('stops background tasks on close by default', async () => { const { sessionDir, workDir } = await hookFixture(); const session = new Session({ kaos: testKaos.withCwd(workDir), @@ -118,7 +118,6 @@ describe('Session lifecycle hooks', () => { homedir: sessionDir, rpc: createSessionRpc(), skills: { explicitDirs: [join(workDir, 'missing-skills')] }, - background: { keepAliveOnExit: false }, }); const agent = await session.createMain(); const { proc, killSpy } = pendingProcess(); @@ -132,6 +131,28 @@ describe('Session lifecycle hooks', () => { expect(agent.background.getTask(taskId)?.status).toBe('killed'); }); + it('keeps background tasks alive on close when keepAliveOnExit is true', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-bg-keepalive', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { keepAliveOnExit: true }, + }); + const agent = await session.createMain(); + const { proc, killSpy } = pendingProcess(); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'keep alive'), + ); + + await session.close(); + + expect(killSpy).not.toHaveBeenCalled(); + expect(agent.background.getTask(taskId)?.status).toBe('running'); + }); + it('lets the environment override config when deciding background task cleanup', async () => { vi.stubEnv('KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT', '0'); const { sessionDir, workDir } = await hookFixture(); From 4e5043b03b2fb03374550dc65d04871bc83e932a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 13:58:35 +0800 Subject: [PATCH 034/476] fix: require AgentSwarm to run alone (#643) --- .changeset/agent-swarm-exclusive-call.md | 6 + docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../policies/agent-swarm-exclusive-deny.ts | 45 ++++++ .../src/agent/permission/policies/index.ts | 3 + packages/agent-core/src/loop/tool-call.ts | 24 ++- packages/agent-core/src/loop/types.ts | 1 + .../builtin/collaboration/agent-swarm.md | 2 + .../agent-core/test/agent/permission.test.ts | 144 +++++++++++++++++- .../permission/default-tool-approve.test.ts | 8 + .../test/loop/api-shape.e2e.test.ts | 3 + .../agent-core/test/loop/hooks.e2e.test.ts | 54 +++++++ .../test/tools/plan-mode-hard-block.test.ts | 14 +- .../planning/exit-plan-mode-telemetry.test.ts | 10 +- 14 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 .changeset/agent-swarm-exclusive-call.md create mode 100644 packages/agent-core/src/agent/permission/policies/agent-swarm-exclusive-deny.ts diff --git a/.changeset/agent-swarm-exclusive-call.md b/.changeset/agent-swarm-exclusive-call.md new file mode 100644 index 000000000..a2da15cae --- /dev/null +++ b/.changeset/agent-swarm-exclusive-call.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Require AgentSwarm tool calls to run alone in a model response. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 0856ff19e..d2935ab03 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -91,7 +91,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 151ee273c..74fd9dbb5 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -91,7 +91,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 diff --git a/packages/agent-core/src/agent/permission/policies/agent-swarm-exclusive-deny.ts b/packages/agent-core/src/agent/permission/policies/agent-swarm-exclusive-deny.ts new file mode 100644 index 000000000..6b242ac5e --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/agent-swarm-exclusive-deny.ts @@ -0,0 +1,45 @@ +import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types'; + +export class AgentSwarmExclusiveDenyPermissionPolicy implements PermissionPolicy { + readonly name = 'agent-swarm-exclusive-deny'; + + evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { + const toolCalls = context.toolCalls; + const agentSwarmCount = toolCalls.filter( + (toolCall) => toolCall.name === 'AgentSwarm', + ).length; + + if (agentSwarmCount === 0) return; + if (agentSwarmCount === 1 && toolCalls.length === 1) return; + + return { + kind: 'deny', + message: + agentSwarmCount > 1 + ? multipleAgentSwarmDeniedMessage(toolCalls.length > agentSwarmCount) + : mixedAgentSwarmDeniedMessage(), + reason: { + agent_swarm_tool_calls: agentSwarmCount, + tool_calls: toolCalls.length, + }, + }; + } +} + +function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string { + const suffix = hasOtherToolCalls + ? ' AgentSwarm also must not be combined with other tools in the same response.' + : ''; + return ( + 'AgentSwarm must be called one swarm at a time. Multiple AgentSwarm calls are not forbidden, ' + + 'but issue them sequentially: call one AgentSwarm, wait for its result, then call the next; ' + + `or merge the work into a single AgentSwarm when one swarm can cover it.${suffix}` + ); +} + +function mixedAgentSwarmDeniedMessage(): string { + return ( + 'AgentSwarm must be the only tool call in a model response. Retry with a single AgentSwarm ' + + 'call by itself, then call any other tools after it returns.' + ); +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index b1243ac2d..334fe0658 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -1,5 +1,6 @@ import type { Agent } from '../..'; import type { PermissionPolicy } from '../types'; +import { AgentSwarmExclusiveDenyPermissionPolicy } from './agent-swarm-exclusive-deny'; import { AutoModeApprovePermissionPolicy } from './auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicy } from './auto-mode-ask-user-question-deny'; import { DefaultToolApprovePermissionPolicy } from './default-tool-approve'; @@ -27,6 +28,8 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy return [ // PreToolUse hook returned a block → deny. new PreToolCallHookPermissionPolicy(agent), + // AgentSwarm is batch-exclusive and must run alone, regardless of permission mode. + new AgentSwarmExclusiveDenyPermissionPolicy(), // auto mode + AskUserQuestion → deny. new AutoModeAskUserQuestionDenyPermissionPolicy(agent), // plan mode: Write/Edit outside the plan file, or TaskStop → deny. diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 311594bc3..1468f9cd2 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -72,6 +72,10 @@ export interface ToolCallStepContext { readonly stepUuid: string; } +interface ToolCallBatchContext extends ToolCallStepContext { + readonly toolCalls: readonly ToolCall[]; +} + type PreflightedToolCall = RunnableToolCall | RejectedToolCall; interface RunnableToolCall { @@ -120,6 +124,7 @@ export async function runToolCallBatch( response: LLMChatResponse, ): Promise { if (response.toolCalls.length === 0) return { stopTurn: false }; + const batchStep: ToolCallBatchContext = { ...step, toolCalls: response.toolCalls }; const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall)); const scheduler = new ToolScheduler(); const pendingResults: Array> = []; @@ -128,13 +133,13 @@ export async function runToolCallBatch( try { for (let index = 0; index < calls.length; index += 1) { const call = calls[index]!; - const prepared = await prepareToolCall(step, call); + const prepared = await prepareToolCall(batchStep, call); pendingResults.push(scheduler.add(prepared.task)); if (prepared.stopBatchAfterThis === true) { stopTurn = true; for (const skippedCall of calls.slice(index + 1)) { - const skippedTask = await prepareSkippedToolCall(step, skippedCall); + const skippedTask = await prepareSkippedToolCall(batchStep, skippedCall); pendingResults.push(scheduler.add(skippedTask)); } break; @@ -145,7 +150,7 @@ export async function runToolCallBatch( // provider order. Await all tasks so each recorded `tool.call` gets a // paired `tool.result`; the caller checks abort before writing `step.end`. for (const pendingResult of pendingResults) { - const result = await finalizePendingToolResult(step, await pendingResult); + const result = await finalizePendingToolResult(batchStep, await pendingResult); if (result.stopTurn === true) stopTurn = true; await step.dispatchEvent({ type: 'tool.result', @@ -235,7 +240,7 @@ function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string } async function prepareToolCall( - step: ToolCallStepContext, + step: ToolCallBatchContext, call: PreflightedToolCall, ): Promise { const settleError = async ( @@ -336,7 +341,7 @@ async function prepareToolCall( } async function prepareSkippedToolCall( - step: ToolCallStepContext, + step: ToolCallBatchContext, call: PreflightedToolCall, ): Promise> { const output = 'Tool skipped because a previous tool call stopped the turn.'; @@ -356,7 +361,7 @@ function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask { const { hooks, signal, turnId, currentStep, llm } = step; @@ -370,6 +375,7 @@ async function runPrepareToolExecutionHook( try { hookResult = await hooks.prepareToolExecution({ toolCall, + toolCalls: step.toolCalls, tool: call.tool, args, turnId, @@ -411,7 +417,7 @@ async function runPrepareToolExecutionHook( } async function runAuthorizeToolExecutionHook( - step: ToolCallStepContext, + step: ToolCallBatchContext, call: RunnableToolCall, args: unknown, execution: RunnableToolExecution, @@ -422,6 +428,7 @@ async function runAuthorizeToolExecutionHook( try { return await hooks.authorizeToolExecution({ toolCall: call.toolCall, + toolCalls: step.toolCalls, tool: call.tool, args, execution, @@ -493,7 +500,7 @@ async function runRunnableToolCall( } async function finalizePendingToolResult( - step: ToolCallStepContext, + step: ToolCallBatchContext, pendingResult: PendingToolResult, ): Promise { const { hooks, signal, turnId, currentStep, llm } = step; @@ -504,6 +511,7 @@ async function finalizePendingToolResult( try { const finalizedResult = await hooks.finalizeToolResult({ toolCall: pendingResult.toolCall, + toolCalls: step.toolCalls, args: pendingResult.args, result: pendingResult.result, turnId, diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 5c59c988f..8459450ef 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -147,6 +147,7 @@ export interface LoopStepHookContext { export interface ToolExecutionHookContext extends LoopStepHookContext { readonly toolCall: ToolCall; + readonly toolCalls: readonly ToolCall[]; readonly tool?: ExecutableTool | undefined; readonly args: unknown; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md index 38efbc69e..816c129e5 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -5,3 +5,5 @@ Use AgentSwarm when many subagents should run the same kind of task over differe Use `resume_agent_ids` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually `continue` if no extra information is needed). You may combine `resume_agent_ids` with `items` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in `items`. Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items. + +If `AgentSwarm` is called, that call must be the only tool call in the response. diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 1246a21ff..a57200c68 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -17,6 +17,7 @@ import { parsePattern, type PermissionRuleMatchExecution, } from '../../src/agent/permission/matches-rule'; +import { AgentSwarmExclusiveDenyPermissionPolicy } from '../../src/agent/permission/policies/agent-swarm-exclusive-deny'; import { AutoModeApprovePermissionPolicy } from '../../src/agent/permission/policies/auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicy } from '../../src/agent/permission/policies/auto-mode-ask-user-question-deny'; import { FallbackAskPermissionPolicy } from '../../src/agent/permission/policies/fallback-ask'; @@ -697,6 +698,7 @@ describe('Permission policy chain', () => { it('keeps built-in policies in document order', () => { expect(createPermissionDecisionPolicies({} as Agent).map((policy) => policy.name)).toEqual([ 'pre-tool-call-hook', + 'agent-swarm-exclusive-deny', 'auto-mode-ask-user-question-deny', 'plan-mode-guard-deny', 'user-configured-deny', @@ -716,6 +718,42 @@ describe('Permission policy chain', () => { ]); }); + it('denies invalid AgentSwarm batches before auto-mode approval', async () => { + const { manager, requestApproval, telemetryTrack } = makePermissionManager(async () => ({ + decision: 'approved', + })); + manager.mode = 'auto'; + const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' }); + + await expect( + manager.beforeToolCall( + hookContext({ + id: 'call_agent_swarm', + toolName: 'AgentSwarm', + toolCalls: [agentSwarmCall, readCall], + }), + ), + ).resolves.toMatchObject({ + block: true, + reason: expect.stringContaining('AgentSwarm must be the only tool call'), + }); + + expect(requestApproval).not.toHaveBeenCalled(); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'agent-swarm-exclusive-deny', + tool_name: 'AgentSwarm', + permission_mode: 'auto', + decision: 'deny', + }), + ); + }); }); describe('Simple permission policy direct behavior', () => { @@ -788,6 +826,99 @@ describe('Simple permission policy direct behavior', () => { ).toBeUndefined(); }); + it('denies AgentSwarm mixed with other tool calls in the same response', () => { + const policy = new AgentSwarmExclusiveDenyPermissionPolicy(); + const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' }); + + expect( + policy.evaluate( + hookContext({ + id: 'call_agent_swarm', + toolName: 'AgentSwarm', + toolCalls: [agentSwarmCall, readCall], + }), + ), + ).toMatchObject({ + kind: 'deny', + message: expect.stringContaining('AgentSwarm must be the only tool call'), + reason: { + agent_swarm_tool_calls: 1, + tool_calls: 2, + }, + }); + expect( + policy.evaluate( + hookContext({ + id: 'call_read', + toolName: 'Read', + args: { path: 'src/a.ts' }, + toolCalls: [agentSwarmCall, readCall], + }), + ), + ).toMatchObject({ kind: 'deny' }); + }); + + it('denies multiple AgentSwarm calls with one-at-a-time guidance', () => { + const policy = new AgentSwarmExclusiveDenyPermissionPolicy(); + const first = toolCall('call_agent_swarm_1', 'AgentSwarm', { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + const second = toolCall('call_agent_swarm_2', 'AgentSwarm', { + description: 'Review tests', + prompt_template: 'Review {{item}}', + items: ['test/a.ts', 'test/b.ts'], + }); + + const result = policy.evaluate( + hookContext({ + id: 'call_agent_swarm_1', + toolName: 'AgentSwarm', + toolCalls: [first, second], + }), + ); + + expect(result).toMatchObject({ + kind: 'deny', + message: expect.stringContaining('Multiple AgentSwarm calls are not forbidden'), + reason: { + agent_swarm_tool_calls: 2, + tool_calls: 2, + }, + }); + expect(result).toMatchObject({ + message: expect.stringContaining('call one AgentSwarm, wait for its result'), + }); + expect(result).toMatchObject({ + message: expect.stringContaining('merge the work into a single AgentSwarm'), + }); + }); + + it('allows a single AgentSwarm call for later permission policies', () => { + const policy = new AgentSwarmExclusiveDenyPermissionPolicy(); + const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + + expect( + policy.evaluate( + hookContext({ + id: 'call_agent_swarm', + toolName: 'AgentSwarm', + toolCalls: [agentSwarmCall], + }), + ), + ).toBeUndefined(); + }); + it('always asks in FallbackAskPermissionPolicy', () => { const policy = new FallbackAskPermissionPolicy(); @@ -3655,6 +3786,7 @@ function hookContext(input: { readonly toolName?: string | undefined; readonly args?: Record | undefined; readonly execution?: PermissionPolicyContext['execution'] | undefined; + readonly toolCalls?: readonly ToolCall[] | undefined; }): PermissionPolicyContext { const toolName = input.toolName ?? 'Bash'; const args = input.args ?? { command: 'printf first', timeout: 60 }; @@ -3662,7 +3794,7 @@ function hookContext(input: { type: 'function', id: input.id, name: toolName, - arguments: JSON.stringify(args), + arguments: JSON.stringify(args), }; return { turnId: '0', @@ -3670,11 +3802,21 @@ function hookContext(input: { signal: new AbortController().signal, llm: {} as PermissionPolicyContext['llm'], toolCall, + toolCalls: input.toolCalls ?? [toolCall], args, execution: input.execution ?? testExecution(toolName, args), }; } +function toolCall(id: string, name: string, args: Record): ToolCall { + return { + type: 'function', + id, + name, + arguments: JSON.stringify(args), + }; +} + function permissionRule( pattern: string, decision: PermissionRule['decision'] = 'allow', diff --git a/packages/agent-core/test/agent/permission/default-tool-approve.test.ts b/packages/agent-core/test/agent/permission/default-tool-approve.test.ts index 3f318acd2..72d4c40c1 100644 --- a/packages/agent-core/test/agent/permission/default-tool-approve.test.ts +++ b/packages/agent-core/test/agent/permission/default-tool-approve.test.ts @@ -20,6 +20,14 @@ function policyContext(toolName: string, args: unknown): PermissionPolicyContext name: toolName, arguments: JSON.stringify(args), } satisfies ToolCall, + toolCalls: [ + { + type: 'function', + id: `call_${toolName}`, + name: toolName, + arguments: JSON.stringify(args), + }, + ], execution: { accesses: ToolAccesses.none(), approvalRule: toolName, diff --git a/packages/agent-core/test/loop/api-shape.e2e.test.ts b/packages/agent-core/test/loop/api-shape.e2e.test.ts index 432578651..be9afbe61 100644 --- a/packages/agent-core/test/loop/api-shape.e2e.test.ts +++ b/packages/agent-core/test/loop/api-shape.e2e.test.ts @@ -299,6 +299,7 @@ function _typeOnlyChecks(): void { const toolCallHookContext: ToolExecutionHookContext = { ...stepHookContext, toolCall: { type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }, + toolCalls: [{ type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }], args: {}, }; void toolCallHookContext; @@ -306,6 +307,7 @@ function _typeOnlyChecks(): void { const _badToolExecutionHookContext: ToolExecutionHookContext = { ...stepHookContext, toolCall: { type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }, + toolCalls: [{ type: 'function', id: 'tc1', name: 'echo', arguments: '{}' }], // @ts-expect-error — tool hooks receive `args`, not the old `input` field. input: {}, }; @@ -484,6 +486,7 @@ function _typeOnlyChecks(): void { }; const prepareToolExecutionHook: PrepareToolExecutionHook = async (ctx) => ({ updatedArgs: ctx.args, + executionMetadata: ctx.toolCalls, }); const finalizeToolResultHook: FinalizeToolResultHook = async (ctx) => ctx.result; const shouldContinueAfterStopHook: ShouldContinueAfterStopHook = async (ctx) => ({ diff --git a/packages/agent-core/test/loop/hooks.e2e.test.ts b/packages/agent-core/test/loop/hooks.e2e.test.ts index dd07e3a34..981d17fb9 100644 --- a/packages/agent-core/test/loop/hooks.e2e.test.ts +++ b/packages/agent-core/test/loop/hooks.e2e.test.ts @@ -161,6 +161,32 @@ describe('runTurn — afterStep hook', () => { }); describe('runTurn — prepareToolExecution hook', () => { + it('receives all tool calls from the same provider response', async () => { + const observedBatches: string[][] = []; + const hooks: LoopHooks = { + prepareToolExecution: async (ctx) => { + observedBatches.push(ctx.toolCalls?.map((toolCall) => toolCall.id) ?? []); + }, + }; + + await runTurn({ + hooks, + tools: [new EchoTool()], + responses: [ + makeToolUseResponse([ + makeToolCall('echo', { text: 'a' }, 'tc-a'), + makeToolCall('echo', { text: 'b' }, 'tc-b'), + ]), + makeEndTurnResponse('done'), + ], + }); + + expect(observedBatches).toEqual([ + ['tc-a', 'tc-b'], + ['tc-a', 'tc-b'], + ]); + }); + it('block:true records an error result without invoking execute', async () => { const echo = new EchoTool(); const hooks: LoopHooks = { @@ -245,6 +271,34 @@ describe('runTurn — prepareToolExecution hook', () => { }); }); +describe('runTurn — authorizeToolExecution hook', () => { + it('receives all tool calls from the same provider response', async () => { + const observedBatches: string[][] = []; + const hooks: LoopHooks = { + authorizeToolExecution: async (ctx) => { + observedBatches.push(ctx.toolCalls?.map((toolCall) => toolCall.id) ?? []); + }, + }; + + await runTurn({ + hooks, + tools: [new EchoTool()], + responses: [ + makeToolUseResponse([ + makeToolCall('echo', { text: 'a' }, 'tc-a'), + makeToolCall('echo', { text: 'b' }, 'tc-b'), + ]), + makeEndTurnResponse('done'), + ], + }); + + expect(observedBatches).toEqual([ + ['tc-a', 'tc-b'], + ['tc-a', 'tc-b'], + ]); + }); +}); + class DescribedEchoTool extends EchoTool { override resolveExecution(args: EchoInput) { const execution = super.resolveExecution(args); diff --git a/packages/agent-core/test/tools/plan-mode-hard-block.test.ts b/packages/agent-core/test/tools/plan-mode-hard-block.test.ts index d0598b129..c01060074 100644 --- a/packages/agent-core/test/tools/plan-mode-hard-block.test.ts +++ b/packages/agent-core/test/tools/plan-mode-hard-block.test.ts @@ -35,15 +35,23 @@ function hookContext(toolName: string, args: unknown): ToolExecutionHookContext turnId: '0', stepNumber: 1, signal, - llm: {}, + llm: {} as ToolExecutionHookContext['llm'], args, toolCall: { type: 'function', id: `call_${toolName}`, name: toolName, - arguments: JSON.stringify(args), + arguments: JSON.stringify(args), } satisfies ToolCall, - } as ToolExecutionHookContext; + toolCalls: [ + { + type: 'function', + id: `call_${toolName}`, + name: toolName, + arguments: JSON.stringify(args), + }, + ], + }; } function policyContext( diff --git a/packages/agent-core/test/tools/planning/exit-plan-mode-telemetry.test.ts b/packages/agent-core/test/tools/planning/exit-plan-mode-telemetry.test.ts index 3ad605beb..2dea41d53 100644 --- a/packages/agent-core/test/tools/planning/exit-plan-mode-telemetry.test.ts +++ b/packages/agent-core/test/tools/planning/exit-plan-mode-telemetry.test.ts @@ -102,8 +102,16 @@ function permissionContext(args: ExitPlanModeInput): PermissionPolicyContext { id: 'call_exit_plan', type: 'function', name: 'ExitPlanMode', - arguments: JSON.stringify(args), + arguments: JSON.stringify(args), }, + toolCalls: [ + { + id: 'call_exit_plan', + type: 'function', + name: 'ExitPlanMode', + arguments: JSON.stringify(args), + }, + ], args, execution: { description: 'Presenting plan and exiting plan mode', From e37d7e5837bf9dc62b45b07c79ebcf0314371a06 Mon Sep 17 00:00:00 2001 From: wenhua020201-arch Date: Thu, 11 Jun 2026 14:31:40 +0800 Subject: [PATCH 035/476] docs: note datasource latest version and manual update flow (#646) * docs: note datasource latest version and manual update flow * docs: align datasource version with marketplace manifest (3.2.0) * docs: tighten datasource update wording --- docs/en/customization/plugins.md | 4 +++- docs/zh/customization/plugins.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index ef43f354c..78f90f8e6 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -59,12 +59,14 @@ Kimi Datasource is the official Kimi Code data plugin. It lets you query financi ### Installation -You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services. When you run Kimi Code with `KIMI_CODE_OAUTH_HOST` or `KIMI_CODE_BASE_URL`, log in under the same environment so the datasource plugin uses the matching credentials and service endpoint. +You must first complete OAuth login with a Kimi Code account via `/login`. The plugin relies on local credentials to access data services. 1. Run `/plugins` and select **Marketplace** 2. Find **Kimi Datasource** and press `Space` to install 3. After installation completes, run `/reload` to activate the plugin +The current latest version is v3.2.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. + ### How to Use Once installed, describe your need in natural language and Kimi Code will automatically invoke the data capabilities. You can also explicitly trigger the data query skill with `/skill:kimi-datasource`. diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 46fe39c08..03d7a29a1 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -59,12 +59,14 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 ### 安装 -需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。当你通过 `KIMI_CODE_OAUTH_HOST` 或 `KIMI_CODE_BASE_URL` 运行 Kimi Code 时,需要在同一环境下登录,这样 datasource 插件会使用匹配的凭据和服务端点。 +需先通过 `/login` 完成 Kimi Code 账号 OAuth 登录,插件依赖本地凭据访问数据服务。 1. 运行 `/plugins`,选择 **Marketplace** 2. 找到 **Kimi Datasource**,按 `Space` 安装 3. 安装完成后运行 `/reload` 重载插件,即可使用 +当前最新版本为 v3.2.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 + ### 使用方式 安装完成后,直接用自然语言描述你的需求,Kimi Code 会自动调用数据能力;也可以通过 `/skill:kimi-datasource` 明确触发数据查询 Skill。 From 1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 14:34:35 +0800 Subject: [PATCH 036/476] fix: allow YOLO when starting swarm tasks (#645) --- .changeset/swarm-yolo-start-option.md | 5 ++++ apps/kimi-code/src/tui/commands/swarm.ts | 6 +++-- .../dialogs/swarm-start-permission-prompt.ts | 8 ++++++- .../kimi-code/test/tui/commands/swarm.test.ts | 23 ++++++++++++++++++- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 6 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 .changeset/swarm-yolo-start-option.md diff --git a/.changeset/swarm-yolo-start-option.md b/.changeset/swarm-yolo-start-option.md new file mode 100644 index 000000000..ed1f2766e --- /dev/null +++ b/.changeset/swarm-yolo-start-option.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a YOLO choice when starting swarm tasks from Manual mode. diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 01e3ae012..540aa5860 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -71,7 +71,7 @@ async function startSwarmWithPermission( prompt: string, choice: SwarmStartPermissionChoice, ): Promise { - if (choice === 'auto') { + if (choice === 'auto' || choice === 'yolo') { if (!(await setPermissionForSwarm(host, choice))) return; } await startSwarmTask(host, prompt); @@ -111,7 +111,9 @@ async function applySwarmMode( } if (enabled && host.state.appState.permissionMode === 'manual') { showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => { - if (choice === 'auto' && !(await setPermissionForSwarm(host, choice))) return; + if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForSwarm(host, choice))) { + return; + } if (!(await setSwarmMode(host, true, 'manual'))) return; renderSwarmModeMarker(host, 'active'); }); diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts index b4da0d851..694c0c0e6 100644 --- a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -3,7 +3,7 @@ import { type StartPermissionOption, } from './start-permission-prompt'; -export type SwarmStartPermissionChoice = 'auto' | 'manual'; +export type SwarmStartPermissionChoice = 'auto' | 'yolo' | 'manual'; export interface SwarmStartPermissionPromptOptions { readonly onSelect: (choice: SwarmStartPermissionChoice) => void; @@ -17,6 +17,12 @@ const OPTIONS: readonly StartPermissionOption[] = [ description: 'Best for swarm tasks. Tools are approved automatically, and questions are skipped.', }, + { + value: 'yolo', + label: 'Switch to YOLO and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, { value: 'manual', label: 'Start in Manual', diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index c9427a710..3e3c9b11a 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -214,7 +214,7 @@ describe('handleSwarmCommand', () => { expect(host.sendNormalUserInput).not.toHaveBeenCalled(); const text = stripAnsi(mountedPicker(host).render(80).join('\n')); expect(text).toContain('Manual mode can block swarm work'); - expect(text).not.toContain('Switch to YOLO and start'); + expect(text).toContain('Switch to YOLO and start'); expect(text).not.toContain('Do not start'); }); @@ -242,6 +242,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); const picker = mountedPicker(host); picker.handleInput(DOWN); + picker.handleInput(DOWN); picker.handleInput(ENTER); await vi.waitFor(() => { @@ -254,6 +255,26 @@ describe('handleSwarmCommand', () => { expectSwarmMarker(host, 'Swarm activated'); }); + it('can start a Manual-mode swarm task after switching to YOLO', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 36cd41421..f6eb04cae 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -48,7 +48,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. Simply toggling does not create an empty plan file | Yes | | `/plan clear` | — | Clear the current plan | No | | `/swarm on\|off` | — | Turn swarm mode on or off without sending a prompt. | Yes | -| `/swarm ` | — | Turn swarm mode on, then send `` as a normal prompt. If the turn completes normally, swarm mode turns off automatically. In `manual` permission mode, Kimi Code asks whether to switch to `auto` before starting. | No | +| `/swarm ` | — | Turn swarm mode on, then send `` as a normal prompt. If the turn completes normally, swarm mode turns off automatically. In `manual` permission mode, Kimi Code asks whether to switch to `auto` or `yolo` before starting. | No | | `/goal [...]` | — | Start or manage an autonomous goal | See below | ::: warning diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 9bd4ef343..e475ddf7d 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -46,7 +46,7 @@ | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。单纯切换不会创建空计划文件 | 是 | | `/plan clear` | — | 清除当前 plan 方案 | 否 | | `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 | -| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | +| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto` 或 `yolo`。 | 否 | | `/goal [...]` | — | 开始或管理目标模式 | 见下文 | ::: warning 注意 From a58b5b20bb42228c72277daba9fa07bb1cd539a6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 11 Jun 2026 14:53:46 +0800 Subject: [PATCH 037/476] fix(skill): polish builtin skills KIMI_CODE_HOME resolution (#644) - custom-theme.md, update-config.md, mcp-config.md now resolve KIMI_CODE_HOME first. - custom-theme.md requires clarifying intent before creating or editing themes. - custom-theme.md falls back to plain-text questions in auto mode. --- .../refine-builtin-skills-home-resolution.md | 6 ++ .../src/skill/builtin/custom-theme.md | 61 ++++++++++++++----- .../src/skill/builtin/mcp-config.md | 21 ++++++- .../src/skill/builtin/update-config.md | 15 ++++- .../test/skill/builtin-custom-theme.test.ts | 4 +- 5 files changed, 84 insertions(+), 23 deletions(-) create mode 100644 .changeset/refine-builtin-skills-home-resolution.md diff --git a/.changeset/refine-builtin-skills-home-resolution.md b/.changeset/refine-builtin-skills-home-resolution.md new file mode 100644 index 000000000..08da24af6 --- /dev/null +++ b/.changeset/refine-builtin-skills-home-resolution.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Polish builtin skills. diff --git a/packages/agent-core/src/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md index 1f4fe9716..9e8dbc0d6 100644 --- a/packages/agent-core/src/skill/builtin/custom-theme.md +++ b/packages/agent-core/src/skill/builtin/custom-theme.md @@ -1,15 +1,35 @@ --- name: custom-theme -description: Create or edit a kimi-code custom color theme — a JSON file under ~/.kimi-code/themes/ that recolors the TUI. Use when the user wants their own theme, asks for a specific palette or mood, or wants to tweak an existing custom theme's colors. +description: Create or edit a kimi-code custom color theme — a JSON file under the resolved KIMI_CODE_HOME data directory that recolors the TUI. Use when the user wants their own theme, asks for a specific palette or mood, or wants to tweak an existing custom theme's colors. --- # Create a kimi-code custom theme (custom-theme) Help the user design, write, and apply a custom color theme for the kimi-code TUI. A theme is a single JSON file; the TUI ships with `dark`, `light`, and `auto`, and any file the user adds becomes selectable alongside them. +## Rules of engagement + +- **Never write a theme until the user has explicitly clarified what they want.** This skill may only run after the user has confirmed light vs dark, the style or mood, any specific colors they care about, and the intended filename. If any of these are missing, ask before creating files. +- **Never assume the data directory is `~/.kimi-code`.** Always resolve `$KIMI_CODE_HOME` first with the Bash command below. +- **Never edit a live theme file in place.** Always create a `.json.new` candidate, validate it, back up the old file, and then `mv` it into place. +- **Never overwrite an existing theme without reading it first.** Read, back up, then overwrite only after the user confirms. + +## Where a theme lives + +The kimi-code runtime resolves the data directory as `KIMI_CODE_HOME` first, falling back to `~/.kimi-code`. Theme files live inside the `themes/` subdirectory of that data directory. + +Before doing anything, resolve the actual data root with Bash so you don't write to the wrong place. Check whether `KIMI_CODE_HOME` is set and fall back to `~/.kimi-code` when it is empty: + +```bash +echo "$KIMI_CODE_HOME" +echo "$HOME/.kimi-code" +``` + +Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved data root — **never assume `~/.kimi-code`**. Theme files live at `/themes/.json`. Create the `themes/` directory if it doesn't exist. + ## What a theme is -- A theme lives at `~/.kimi-code/themes/.json` (or `$KIMI_CODE_HOME/themes/.json` when that variable is set). Create the `themes/` directory if it doesn't exist. +- A theme lives at `/themes/.json`. - **The filename is the theme name**: `ember.json` shows up in the `/theme` picker as `Custom: ember`. - Shape: @@ -67,24 +87,33 @@ Only set tokens from this set — unknown keys are silently ignored at load. If - **What style / mood?** e.g. warm vs cool, vivid vs muted, high vs low contrast, a named vibe ("nord", "solarized", "sunset"), or a base to start from (an existing theme, or `dark` / `light`). - **Any specific colors?** Whether they have exact hex values to anchor on (a brand color, a preferred `primary`, etc.). - Use **AskUserQuestion** for the discrete choices (light vs dark, a few style options); use a plain question for free-form input like specific hex values. Don't start picking colors until you at least know light-vs-dark and the rough style. -2. **Pick a starting point.** - - Tweaking an existing custom theme: **Read** `~/.kimi-code/themes/.json` first — never overwrite a theme you haven't read. - - Starting fresh: build a `colors` object from the token table. You can `ls ~/.kimi-code/themes/` and Read one of the user's existing themes as a reference for the format. -3. **Choose colors deliberately.** + For the discrete choices (light vs dark, a few style options), prefer **AskUserQuestion** if it is available. If you are running in **auto mode** and `AskUserQuestion` is unavailable, ask the same question as a plain-text message with clear numbered or bulleted options, and wait for the user's reply. Don't start picking colors until you at least know light-vs-dark and the rough style. + +2. **Resolve the actual theme directory and current theme(s).** + - Resolve the data root by checking `echo "$KIMI_CODE_HOME"`; if empty, use `echo "$HOME/.kimi-code"`. Use `/themes` for every subsequent step. + - If tweaking an existing custom theme, **Read** `/themes/.json` first — never overwrite a theme you haven't read. + - Starting fresh: build a `colors` object from the token table. You can `ls /themes/` and Read one of the user's existing themes as a reference for the format. + +3. **Pick a starting point and choose colors deliberately.** - Every value is a 6-digit hex `#RRGGBB` (not 3-digit, not a named color). - Keep contrast usable against the user's terminal background: don't let `text` / `textDim` sit too close to the background, and keep `success` / `warning` / `error` clearly distinguishable from each other. - `primary` is the most-seen color (links, selection, focus) — make it readable and distinct from `text`. - `roleUser` is the one role color meant to stand on its own — give it a distinct hue. -4. **Write the file** to `~/.kimi-code/themes/.json` with **Write** for a new theme (pick a short kebab-case filename). When editing an existing theme, prefer **Edit** on just the color(s) that change so the rest stays intact — and back it up first (see Don'ts). -5. **Validate.** Confirm the file is valid JSON and every `colors` value matches `^#[0-9a-fA-F]{6}$`. A quick check with **Bash**: - ```bash - node -e 'const p=require("os").homedir()+"/.kimi-code/themes/.json";const c=(require(p).colors)||{};const bad=Object.entries(c).filter(([,v])=>!/^#[0-9a-fA-F]{6}$/.test(v));console.log(bad.length?["invalid:",...bad]:"all hex valid")' - ``` +4. **Create a candidate file; never edit the live theme in place.** + - Use Bash to create a candidate. If the target theme already exists, copy it verbatim: `cp .json .json.new` (inside `/themes/`). If it doesn't exist, use **Write** to create a minimal skeleton named `.json.new`. + - Use **Edit** on the candidate to change only the intended keys. Keep every existing entry, comment, and formatting intact. - Invalid values are silently skipped at load (they fall back to the base palette; not fatal), but fix them so the theme renders as intended. -6. **Tell the user how to apply it** (next section). +5. **Validate the candidate before overwriting.** + - Read the candidate with **Read** to visually confirm it is well-formed JSON and that every `colors` value is a full 6-digit hex `#RRGGBB` (not 3-digit, not a named color). + - Invalid hex values are silently skipped at load (they fall back to the base palette), but fix them so the theme renders as intended. + +6. **Back up and overwrite.** + - Back up the old file first — **always** create a new timestamped backup and never overwrite an existing backup: `cp .json ".json.$(date +%Y%m%d-%H%M%S).bak"`. + - If the target didn't exist, skip the backup. + - Overwrite with the candidate: `mv .json.new .json`. + +7. **Tell the user how to apply it** (next section). ## Applying the theme @@ -94,7 +123,9 @@ Only set tokens from this set — unknown keys are silently ignored at load. If ## Don'ts +- **Don't start creating or editing a theme until the user has clarified light/dark, style/mood, any specific colors, and the filename.** If anything is unclear, ask — don't guess. - Don't invent token names — only use the documented set; unknown keys are silently ignored. - Don't write 3-digit hex or named colors — use full `#RRGGBB`. -- Before overwriting an existing theme file, **read it and back it up** (e.g. `cp .json ".json.$(date +%Y%m%d-%H%M%S).bak"`) so the user can recover. +- Never edit the live theme file in place; work through a candidate and validate before `mv`. +- Before overwriting an existing theme file, **read it and back it up** so the user can recover. - Don't tell the user to restart the app to apply a theme — `/theme` or `/reload-tui` is enough. diff --git a/packages/agent-core/src/skill/builtin/mcp-config.md b/packages/agent-core/src/skill/builtin/mcp-config.md index be9bfd695..1a38ebd26 100644 --- a/packages/agent-core/src/skill/builtin/mcp-config.md +++ b/packages/agent-core/src/skill/builtin/mcp-config.md @@ -38,10 +38,25 @@ tools exist and the user didn't name one, ask which. ## Config edit Config lives in three files; on key collision, later entries in this -precedence order override earlier ones: +precedence order override earlier ones. -- User-global: `~/.kimi-code/mcp.json` (or `$KIMI_CODE_HOME/mcp.json` if - set). Use for servers you want everywhere. +The kimi-code runtime resolves the user-global directory as `KIMI_CODE_HOME` +first, falling back to `~/.kimi-code`. Before touching the user-global file, +resolve the actual directory with Bash so you don't read or write the wrong +one. Check whether `KIMI_CODE_HOME` is set and fall back to `~/.kimi-code` +when it is empty: + +```bash +echo "$KIMI_CODE_HOME" +echo "$HOME/.kimi-code" +``` + +Use the first line when it is non-empty; otherwise use the second line. In the +rest of this skill, `` means that resolved data root — +**never assume `~/.kimi-code`**. + +- User-global: `/mcp.json`. Use for servers you want + everywhere. - Project-root: `/.mcp.json`, where project root is found by walking up from `` to the nearest `.git`. Use for Claude-compatible, repo-shared, or cross-agent servers. diff --git a/packages/agent-core/src/skill/builtin/update-config.md b/packages/agent-core/src/skill/builtin/update-config.md index 4b033d506..f9b694672 100644 --- a/packages/agent-core/src/skill/builtin/update-config.md +++ b/packages/agent-core/src/skill/builtin/update-config.md @@ -9,7 +9,16 @@ Help the user inspect, change, and validate kimi-code's configuration files. The ## The two config files -kimi-code has two TOML config files, both under `~/.kimi-code/` (or under `KIMI_CODE_HOME` when set), both snake_case, but with different ownership — decide which one the user means before doing anything: +kimi-code has two TOML config files, both under `/`, both snake_case, but with different ownership — decide which one the user means before doing anything. + +The runtime resolves the data directory as `KIMI_CODE_HOME` first, falling back to `~/.kimi-code`. Before doing anything, resolve the actual directory with Bash so you don't write to the wrong place. Check whether `KIMI_CODE_HOME` is set and fall back to `~/.kimi-code` when it is empty: + +```bash +echo "$KIMI_CODE_HOME" +echo "$HOME/.kimi-code" +``` + +Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. - **`config.toml`** — agent / runtime settings: `default_model`, `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. @@ -31,7 +40,7 @@ https://moonshotai.github.io/kimi-code/en/configuration/config-files.html Before any modification, use **Read** on the target config file (decide whether it's `config.toml` or `tui.toml` per the above): -- Location: `~/.kimi-code/config.toml` or `~/.kimi-code/tui.toml` (under `$KIMI_CODE_HOME/` when set). For other scopes/files, defer to the official docs. +- Location: `/config.toml` or `/tui.toml`. For other scopes/files, defer to the official docs. - A missing or empty file is fine — you'll create a minimal skeleton later. - If the file exists but **fails to parse as TOML**, report the error verbatim and **stop** — never overwrite a broken file in place (it could destroy the user's existing config). @@ -53,7 +62,7 @@ Don't edit the target file in place, and **don't rewrite it from scratch** — i 1. **Clarify intent**: which key, what value, and which file (`config.toml` or `tui.toml`). Ask in one line if ambiguous; for discrete choices (e.g. scope) AskUserQuestion is fine, but use plain questions for free-form input. 2. **Read the target file** (Prerequisite 2): Read it to understand the current state and confirm it parses. 3. **Copy out a candidate (do not create from scratch)**: use **Bash** to copy the target verbatim — `cp config.toml config-new.toml` (same directory, `-new` suffix; for tui.toml, `cp tui.toml tui-new.toml`). **Leave the original untouched for now.** - - Only when the target doesn't exist (nothing to copy) should you use **Write** to create a minimal skeleton candidate (e.g. just the comment line `# ~/.kimi-code/config.toml`). + - Only when the target doesn't exist (nothing to copy) should you use **Write** to create a minimal skeleton candidate (e.g. just the comment line `# /config.toml`). 4. **Edit the candidate**: use the **Edit** tool on the candidate to **change/add only the target key** — never rewrite the whole file. That way every existing section, entry, comment, and bit of formatting stays exactly as-is; only what should change changes. The candidate is identical to the original, so use the content you read in step 2 to locate the Edit anchor. Check the change against the official docs (key / section / value type / allowed values, snake_case). 5. **Validate the candidate** (see Capability 3, via `kimi doctor`). **If anything fails, keep Editing the candidate and re-validate, looping until it all passes.** 6. **Back up and overwrite** (only after validation fully passes): diff --git a/packages/agent-core/test/skill/builtin-custom-theme.test.ts b/packages/agent-core/test/skill/builtin-custom-theme.test.ts index 473fdf5bf..37de8c739 100644 --- a/packages/agent-core/test/skill/builtin-custom-theme.test.ts +++ b/packages/agent-core/test/skill/builtin-custom-theme.test.ts @@ -14,11 +14,11 @@ describe('builtin skill: custom-theme', () => { expect(CUSTOM_THEME_SKILL.metadata.disableModelInvocation).toBe(true); }); - it('pins the docs token reference and points users at ~/.kimi-code/themes and /theme', () => { + it('pins the docs token reference and points users at KIMI_CODE_HOME/themes and /theme', () => { const content = CUSTOM_THEME_SKILL.content; expect(content).toContain('customization/themes.html'); expect(content).toContain('FetchURL'); - expect(content).toContain('~/.kimi-code/themes'); + expect(content).toContain('/themes'); expect(content).toContain('/theme'); // every documented token should be named so the model knows the full set for (const token of [ From 54302ad612294056a47ada74b76737f2284861b5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 15:37:09 +0800 Subject: [PATCH 038/476] fix: scope interactive agent requests (#648) --- .changeset/steady-agent-scopes.md | 6 + .../src/tui/controllers/btw-panel.ts | 9 +- apps/kimi-code/src/tui/kimi-tui.ts | 10 +- .../test/tui/kimi-tui-message-flow.test.ts | 9 +- .../kimi-code/test/tui/message-replay.test.ts | 10 +- packages/node-sdk/src/kimi-harness.ts | 4 +- packages/node-sdk/src/rpc.ts | 12 +- .../test/session-prompt-events.test.ts | 6 +- .../test/session-prompt-input.test.ts | 122 +++++++++++++++++- 9 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 .changeset/steady-agent-scopes.md diff --git a/.changeset/steady-agent-scopes.md b/.changeset/steady-agent-scopes.md new file mode 100644 index 000000000..fdf9bd239 --- /dev/null +++ b/.changeset/steady-agent-scopes.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Prevent overlapping interactive agent requests from using the wrong active agent. diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index b045f8701..21406f329 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -191,14 +191,7 @@ export class BtwPanelController { } private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { - const previousAgentId = this.host.harness.interactiveAgentId; - this.host.harness.interactiveAgentId = agentId; - try { - // SDK RPC methods snapshot interactiveAgentId before their first await. - return fn(); - } finally { - this.host.harness.interactiveAgentId = previousAgentId; - } + return this.host.harness.withInteractiveAgent(agentId, fn); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index bf44fc103..dcddb8987 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -838,10 +838,11 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { - this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; - this.sendMessageInternal(session, item.text, { - parts: item.parts, - imageAttachmentIds: item.imageAttachmentIds, + this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { + this.sendMessageInternal(session, item.text, { + parts: item.parts, + imageAttachmentIds: item.imageAttachmentIds, + }); }); } @@ -1131,7 +1132,6 @@ export class KimiTUI { this.streamingUI.discardPending(); this.state.queuedMessages = []; this.state.swarmModeEntry = undefined; - this.harness.interactiveAgentId = MAIN_AGENT_ID; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0fd3b4a47..d069dd112 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -199,6 +200,7 @@ function makeSession(overrides: Record = {}) { } function makeHarness(session = makeSession(), overrides: Record = {}) { + const interactiveAgentScope = new AsyncLocalStorage(); return { getConfig: vi.fn(async () => ({ models: { @@ -213,7 +215,12 @@ function makeHarness(session = makeSession(), overrides: Record close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - interactiveAgentId: 'main', + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(), diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 85ce19db9..c7289a0ba 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import type { AgentReplayRecord, BackgroundTaskInfo, @@ -197,6 +199,7 @@ function makeSession( } function makeHarness(initialSession: Session) { + const interactiveAgentScope = new AsyncLocalStorage(); return { getConfig: vi.fn(async () => ({ models: { @@ -212,7 +215,12 @@ function makeHarness(initialSession: Session) { track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), - interactiveAgentId: 'main', + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), auth: { status: vi.fn(), login: vi.fn(), diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 5f7e7f4ae..d54d989d0 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -72,8 +72,8 @@ export class KimiHarness { return this.rpc.interactiveAgentId; } - set interactiveAgentId(agentId: string) { - this.rpc.interactiveAgentId = agentId; + withInteractiveAgent(agentId: string, fn: () => T): T { + return this.rpc.withInteractiveAgent(agentId, fn); } track(event: string, properties?: TelemetryProperties): void { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index c9fcd37b1..ebebf9d90 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import { ErrorCodes, makeErrorPayload, @@ -98,11 +100,19 @@ export interface ReconnectMcpServerRpcInput extends SessionIdRpcInput { type ResolvedCoreAPI = RPCMethods; export abstract class SDKRpcClientBase { - interactiveAgentId = MAIN_AGENT_ID; + private readonly interactiveAgentScope = new AsyncLocalStorage(); private readonly eventListeners = new Set<(event: Event) => void>(); private readonly approvalHandlers = new Map(); private readonly questionHandlers = new Map(); + get interactiveAgentId(): string { + return this.interactiveAgentScope.getStore() ?? MAIN_AGENT_ID; + } + + withInteractiveAgent(agentId: string, fn: () => T): T { + return this.interactiveAgentScope.run(agentId, fn); + } + protected abstract getRpc(): Promise; async createSession(input: CreateSessionOptions): Promise { diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 00e47530b..6845ee6bc 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -337,10 +337,12 @@ describe('Session.prompt events', () => { ); const agentId = await session.startBtw(); - harness.interactiveAgentId = agentId; - await session.prompt('What are you working on right now?'); + await harness.withInteractiveAgent(agentId, () => + session.prompt('What are you working on right now?'), + ); await done; unsubscribe(); + expect(harness.interactiveAgentId).toBe('main'); const started = events.find( (event) => diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index 6a6cf0f77..f88c71000 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -1,8 +1,65 @@ import { describe, expect, it, vi } from 'vitest'; +import type { CoreAPI, RPCMethods } from '@moonshot-ai/agent-core'; -import type { SDKRpcClientBase } from '../src/rpc'; +import { SDKRpcClientBase } from '../src/rpc'; import { Session } from '../src/session'; +class CapturingRpc extends SDKRpcClientBase { + readonly promptCalls: unknown[] = []; + readonly enterPlanCalls: unknown[] = []; + readonly cancelPlanCalls: unknown[] = []; + readonly getPlanCalls: unknown[] = []; + readonly clearPlanCalls: unknown[] = []; + readonly setModelCalls: unknown[] = []; + private getRpcDelay: Promise | undefined; + private getRpcCallCount = 0; + private readonly getRpcWaiters = new Set<() => void>(); + + delayGetRpcUntil(promise: Promise): void { + this.getRpcDelay = promise; + } + + waitForGetRpcCalls(count: number): Promise { + if (this.getRpcCallCount >= count) return Promise.resolve(); + return new Promise((resolve) => { + const check = () => { + if (this.getRpcCallCount < count) return; + this.getRpcWaiters.delete(check); + resolve(); + }; + this.getRpcWaiters.add(check); + }); + } + + protected async getRpc(): Promise> { + this.getRpcCallCount += 1; + for (const waiter of this.getRpcWaiters) waiter(); + if (this.getRpcDelay !== undefined) await this.getRpcDelay; + return { + prompt: async (input: unknown) => { + this.promptCalls.push(input); + }, + setModel: async (input: unknown) => { + this.setModelCalls.push(input); + return { model: 'captured-model' }; + }, + enterPlan: async (input: unknown) => { + this.enterPlanCalls.push(input); + }, + cancelPlan: async (input: unknown) => { + this.cancelPlanCalls.push(input); + }, + getPlan: async (input: unknown) => { + this.getPlanCalls.push(input); + return null; + }, + clearPlan: async (input: unknown) => { + this.clearPlanCalls.push(input); + }, + } as unknown as RPCMethods; + } +} + describe('Session.prompt input normalization', () => { it('passes multimodal prompt parts through to the core RPC client', async () => { const prompt = vi.fn(async () => {}); @@ -38,4 +95,67 @@ describe('Session.prompt input normalization', () => { sessionId: 'ses_btw_start', }); }); + + it('scopes interactive agent id across awaited session operations', async () => { + const rpc = new CapturingRpc(); + const session = new Session({ + id: 'ses_scoped_agent', + workDir: '/tmp/work', + rpc, + }); + + await rpc.withInteractiveAgent('agent-btw', async () => { + await Promise.resolve(); + await session.prompt('side question'); + await session.setPlanMode(true); + await session.getPlan(); + await session.clearPlan(); + await session.setPlanMode(false); + expect(rpc.interactiveAgentId).toBe('agent-btw'); + }); + + expect(rpc.interactiveAgentId).toBe('main'); + expect(rpc.promptCalls).toEqual([ + { + sessionId: 'ses_scoped_agent', + agentId: 'agent-btw', + input: [{ type: 'text', text: 'side question' }], + }, + ]); + expect(rpc.enterPlanCalls).toEqual([{ sessionId: 'ses_scoped_agent', agentId: 'agent-btw' }]); + expect(rpc.getPlanCalls).toEqual([{ sessionId: 'ses_scoped_agent', agentId: 'agent-btw' }]); + expect(rpc.clearPlanCalls).toEqual([{ sessionId: 'ses_scoped_agent', agentId: 'agent-btw' }]); + expect(rpc.cancelPlanCalls).toEqual([{ sessionId: 'ses_scoped_agent', agentId: 'agent-btw' }]); + }); + + it('isolates overlapping interactive agent scopes while RPC resolution is pending', async () => { + let releaseRpc!: () => void; + const getRpcDelay = new Promise((resolve) => { + releaseRpc = resolve; + }); + const rpc = new CapturingRpc(); + rpc.delayGetRpcUntil(getRpcDelay); + const session = new Session({ + id: 'ses_overlapping_agents', + workDir: '/tmp/work', + rpc, + }); + + const first = rpc.withInteractiveAgent('agent-a', () => session.setModel('model-a')); + const second = rpc.withInteractiveAgent('agent-b', () => session.setModel('model-b')); + await rpc.waitForGetRpcCalls(2); + + expect(rpc.setModelCalls).toEqual([]); + releaseRpc(); + await Promise.all([first, second]); + + expect(rpc.interactiveAgentId).toBe('main'); + expect(rpc.setModelCalls).toHaveLength(2); + expect(rpc.setModelCalls).toEqual( + expect.arrayContaining([ + { sessionId: 'ses_overlapping_agents', agentId: 'agent-a', model: 'model-a' }, + { sessionId: 'ses_overlapping_agents', agentId: 'agent-b', model: 'model-b' }, + ]), + ); + }); }); From a2c5e1be25484f7c52f729e333196c485f83b84c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 15:50:33 +0800 Subject: [PATCH 039/476] fix: add minor improvements for Mira (#649) --- .changeset/runtime-support-updates.md | 7 ++++ .../kimi-code/test/tui/message-replay.test.ts | 19 +++++++---- apps/vis/server/src/lib/wire-reader.ts | 2 +- packages/agent-core/package.json | 12 +++---- .../permission/policies/file-access-ask.ts | 2 -- .../src/agent/permission/policies/index.ts | 2 +- packages/agent-core/src/agent/replay/index.ts | 12 ++++--- packages/agent-core/src/logging/sinks.ts | 3 -- .../agent-core/src/mcp/connection-manager.ts | 32 +++++++++++++++++++ packages/agent-core/src/rpc/core-impl.ts | 2 +- packages/agent-core/src/rpc/resumed.ts | 4 ++- packages/agent-core/src/skill/types.ts | 2 +- .../agent-core/test/agent/permission.test.ts | 10 +++--- packages/agent-core/test/agent/resume.test.ts | 16 ++++++---- packages/kosong/src/providers/kimi-files.ts | 1 + .../test/resume.integration.test.ts | 2 +- 16 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 .changeset/runtime-support-updates.md diff --git a/.changeset/runtime-support-updates.md b/.changeset/runtime-support-updates.md new file mode 100644 index 000000000..050bed3d8 --- /dev/null +++ b/.changeset/runtime-support-updates.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index c7289a0ba..8a02b505b 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -23,6 +23,8 @@ vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); type GoalReplayRecord = Extract; +const REPLAY_TIME = 1_700_000_000_000; + function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -70,6 +72,7 @@ function message( } = {}, ): AgentReplayRecord { return { + time: REPLAY_TIME, type: 'message', message: { role, @@ -122,6 +125,7 @@ function goalReplay( change: GoalReplayRecord['change'], ): GoalReplayRecord { return { + time: REPLAY_TIME, type: 'goal_updated', snapshot, change, @@ -988,11 +992,12 @@ describe('KimiTUI resume message replay', () => { it('renders plan permission and approval replay notices', async () => { const driver = await replayIntoDriver([ - { type: 'plan_updated', enabled: true }, - { type: 'permission_updated', mode: 'auto' }, - { type: 'permission_updated', mode: 'yolo' }, - { type: 'permission_updated', mode: 'manual' }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: true }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'auto' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'yolo' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'manual' }, { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -1006,7 +1011,7 @@ describe('KimiTUI resume message replay', () => { }, }, }, - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); @@ -1025,6 +1030,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_reject', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -1042,6 +1048,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_final', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 1, @@ -1064,7 +1071,7 @@ describe('KimiTUI resume message replay', () => { ], { toolCallId: 'call_exit_final' }, ), - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); diff --git a/apps/vis/server/src/lib/wire-reader.ts b/apps/vis/server/src/lib/wire-reader.ts index b077e1773..67ee4cd20 100644 --- a/apps/vis/server/src/lib/wire-reader.ts +++ b/apps/vis/server/src/lib/wire-reader.ts @@ -5,7 +5,7 @@ import { migrateWireRecord, resolveWireMigrations, type WireMigration, -} from '@moonshot-ai/agent-core/agent/records/migration'; +} from '@moonshot-ai/agent-core/agent/records/migration/index'; import type { AgentRecord, WireEntry } from './agent-record-types'; diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index a74f15af6..a6649cec8 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -37,13 +37,13 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./agent/records/migration": { - "types": "./src/agent/records/migration/index.ts", - "default": "./src/agent/records/migration/index.ts" + "./package.json": { + "types": "./package.json", + "default": "./package.json" }, - "./session/store": { - "types": "./src/session/store/index.ts", - "default": "./src/session/store/index.ts" + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" } }, "scripts": { diff --git a/packages/agent-core/src/agent/permission/policies/file-access-ask.ts b/packages/agent-core/src/agent/permission/policies/file-access-ask.ts index f71d4b4b1..8c1105062 100644 --- a/packages/agent-core/src/agent/permission/policies/file-access-ask.ts +++ b/packages/agent-core/src/agent/permission/policies/file-access-ask.ts @@ -14,8 +14,6 @@ import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult export class SensitiveFileAccessAskPermissionPolicy implements PermissionPolicy { readonly name = 'sensitive-file-access-ask'; - constructor(private readonly agent: Agent) {} - evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { const access = fileAccesses(context).find((fileAccess) => isSensitiveFile(fileAccess.path), diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 334fe0658..291d0f1b3 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -49,7 +49,7 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy // EnterPlanMode, Write/Edit on the plan file, or ExitPlanMode with no actionable plan_review → approve. new PlanModeToolApprovePermissionPolicy(agent), // Access touches a sensitive file (.env, SSH key, credentials) → ask. - new SensitiveFileAccessAskPermissionPolicy(agent), + new SensitiveFileAccessAskPermissionPolicy(), // Access touches .git or a git control-dir path → ask. new GitControlPathAccessAskPermissionPolicy(agent), // yolo mode → approve. diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts index acb760055..c5f851869 100644 --- a/packages/agent-core/src/agent/replay/index.ts +++ b/packages/agent-core/src/agent/replay/index.ts @@ -1,15 +1,19 @@ import type { Agent } from '..'; -import type { AgentReplayRecord } from '../..'; +import type { AgentReplayRecord, AgentReplayRecordPayload } from '../..'; import type { ContextMessage } from '../context'; export class ReplayBuilder { + captureLiveRecords = false; protected readonly records: AgentReplayRecord[] = []; constructor(public readonly agent: Agent) {} - push(record: AgentReplayRecord): void { - if (this.agent.records.restoring) { - this.records.push(record); + push(record: AgentReplayRecordPayload): void { + if (this.captureLiveRecords || this.agent.records.restoring) { + this.records.push({ + ...record, + time: this.agent.records.restoring?.time ?? Date.now(), + }); } } diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/logging/sinks.ts index e7ceb23be..ef23eb101 100644 --- a/packages/agent-core/src/logging/sinks.ts +++ b/packages/agent-core/src/logging/sinks.ts @@ -35,7 +35,6 @@ export class RotatingFileSink implements Sink { private pending: string[] = []; private dropped = 0; private closed = false; - private failed = false; private lastStderrNotice = 0; private currentBytes = -1; private directorySynced = false; @@ -103,7 +102,6 @@ export class RotatingFileSink implements Sink { this.directorySynced = true; } - this.failed = false; return true; } catch (error) { this.noteFailure(error); @@ -212,7 +210,6 @@ export class RotatingFileSink implements Sink { } private noteFailure(error: unknown): void { - this.failed = true; const now = Date.now(); if (now - this.lastStderrNotice < STDERR_NOTICE_INTERVAL_MS) return; this.lastStderrNotice = now; diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index fb3b861f2..30f269e1c 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -155,6 +155,38 @@ export class McpConnectionManager { return initialLoad; } + async connect(name: string, config: McpServerConfig): Promise { + const previous = this.entries.get(name); + if (previous !== undefined) { + await this.closeClient(previous); + } + const disabled = config.enabled === false; + const entry: InternalEntry = { + name, + config, + attemptId: 0, + status: disabled ? 'disabled' : 'pending', + }; + this.entries.set(name, entry); + this.emit(entry); + if (!disabled) { + await this.connectOne(entry, this.beginConnectAttempt(entry)); + } + } + + async remove(name: string): Promise { + const entry = this.entries.get(name); + if (entry === undefined) return false; + await this.closeClient(entry); + entry.status = 'disabled'; + entry.tools = undefined; + entry.enabledNames = undefined; + entry.error = undefined; + this.emit(entry); + this.entries.delete(name); + return true; + } + waitForInitialLoad(signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (signal === undefined) return this.initialLoad; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 86db94e96..c8edc631d 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -37,7 +37,7 @@ import { type OAuthTokenProviderResolver } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; -import { normalizeWorkDir, SessionStore } from '../session/store'; +import { normalizeWorkDir, SessionStore } from '../session/store/index'; import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from '../telemetry'; import type { CoreRPCClient } from './client'; import type { diff --git a/packages/agent-core/src/rpc/resumed.ts b/packages/agent-core/src/rpc/resumed.ts index 9f3e3b129..4fc635331 100644 --- a/packages/agent-core/src/rpc/resumed.ts +++ b/packages/agent-core/src/rpc/resumed.ts @@ -14,7 +14,7 @@ import type { SessionSummary } from '#/rpc/core-api'; import type { UsageStatus } from '#/rpc/events'; import type { SessionMeta } from '#/session'; -export type AgentReplayRecord = +export type AgentReplayRecordPayload = | { type: 'message'; message: ContextMessage } | { type: 'goal_updated'; @@ -26,6 +26,8 @@ export type AgentReplayRecord = | { type: 'permission_updated'; mode: PermissionMode } | { type: 'approval_result'; record: PermissionApprovalResultRecord }; +export type AgentReplayRecord = { readonly time: number } & AgentReplayRecordPayload; + export interface ResumedAgentState { readonly type: AgentType; readonly config: AgentConfigData; diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 21a44db60..e7485fc16 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -69,7 +69,7 @@ export function isUserActivatableSkillType(type: string | undefined): boolean { } export function isSupportedSkillType(type: string | undefined): boolean { - return isUserActivatableSkillType(type); + return isUserActivatableSkillType(type) || type === 'reference'; } export function summarizeSkill(skill: SkillDefinition): SkillSummary { diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index a57200c68..21460b5fc 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -2463,10 +2463,12 @@ describe('Agent-local approve for session', () => { ctx.dispatch(event); - expect(ctx.agent.replayBuilder.buildResult()).toContainEqual({ - type: 'approval_result', - record: event, - }); + expect(ctx.agent.replayBuilder.buildResult()).toContainEqual( + expect.objectContaining({ + type: 'approval_result', + record: event, + }), + ); expect(ctx.agent.permission.data().rules).toEqual([]); }); }); diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 82d6e56ca..83df5fbc2 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -313,14 +313,16 @@ describe('Agent resume', () => { await ctx.agent.resume(); - expect(ctx.agent.replayBuilder.buildResult()).toContainEqual({ - type: 'message', - message: expect.objectContaining({ - role: 'tool', - toolCallId: 'call_failed_bash', - isError: true, + expect(ctx.agent.replayBuilder.buildResult()).toContainEqual( + expect.objectContaining({ + type: 'message', + message: expect.objectContaining({ + role: 'tool', + toolCallId: 'call_failed_bash', + isError: true, + }), }), - }); + ); }); it('rebuilds goal completion replay cards without adding model-visible context', async () => { diff --git a/packages/kosong/src/providers/kimi-files.ts b/packages/kosong/src/providers/kimi-files.ts index c0ea87c4f..75ca48e5e 100644 --- a/packages/kosong/src/providers/kimi-files.ts +++ b/packages/kosong/src/providers/kimi-files.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { Blob, File } from 'node:buffer'; import { ChatProviderError } from '#/errors'; import type { VideoURLPart } from '#/message'; diff --git a/packages/migration-legacy/test/resume.integration.test.ts b/packages/migration-legacy/test/resume.integration.test.ts index 0383adf56..74d2e6455 100644 --- a/packages/migration-legacy/test/resume.integration.test.ts +++ b/packages/migration-legacy/test/resume.integration.test.ts @@ -33,7 +33,7 @@ import { SessionStore, encodeWorkDirKey, normalizeWorkDir, -} from '@moonshot-ai/agent-core/session/store'; +} from '@moonshot-ai/agent-core/session/store/index'; import { Session, type SDKSessionRPC } from '@moonshot-ai/agent-core'; import { LocalKaos } from '@moonshot-ai/kaos'; From ff803273440f3a2ff53d2c529c6fc892fde1d93f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 17:21:32 +0800 Subject: [PATCH 040/476] fix: propagate kaos env overlays (#654) --- .changeset/propagate-exec-env-overrides.md | 7 ++++ packages/acp-adapter/src/kaos-acp.ts | 4 +++ packages/acp-adapter/test/kaos-acp.test.ts | 25 +++++++++++++ .../agent-core/test/agent/harness/agent.ts | 1 + .../agent-core/test/tools/bash-env.test.ts | 33 +++++++++++++++++ .../test/tools/fixtures/fake-kaos.ts | 35 ++++++++++++++++--- packages/kaos/src/kaos.ts | 7 ++++ packages/kaos/src/local.ts | 29 +++++++++++++-- packages/kaos/src/ssh.ts | 29 ++++++++++++--- packages/kaos/test/local.test.ts | 29 +++++++++++++++ packages/kaos/test/ssh.test.ts | 10 +++++- 11 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 .changeset/propagate-exec-env-overrides.md diff --git a/.changeset/propagate-exec-env-overrides.md b/.changeset/propagate-exec-env-overrides.md new file mode 100644 index 000000000..3839ed69d --- /dev/null +++ b/.changeset/propagate-exec-env-overrides.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kaos": patch +"@moonshot-ai/acp-adapter": patch +"@moonshot-ai/kimi-code": patch +--- + +Propagate configured execution environment overrides across spawned processes. diff --git a/packages/acp-adapter/src/kaos-acp.ts b/packages/acp-adapter/src/kaos-acp.ts index 70ed8614f..40f9759ef 100644 --- a/packages/acp-adapter/src/kaos-acp.ts +++ b/packages/acp-adapter/src/kaos-acp.ts @@ -89,6 +89,10 @@ export class AcpKaos implements Kaos { return new AcpKaos(this.conn, this.sessionId, this.inner.withCwd(cwd)); } + withEnv(env: Record): Kaos { + return new AcpKaos(this.conn, this.sessionId, this.inner.withEnv(env)); + } + stat(path: string, options?: { followSymlinks?: boolean }): Promise { return this.inner.stat(path, options); } diff --git a/packages/acp-adapter/test/kaos-acp.test.ts b/packages/acp-adapter/test/kaos-acp.test.ts index 4564f2c6d..5f8d8e69c 100644 --- a/packages/acp-adapter/test/kaos-acp.test.ts +++ b/packages/acp-adapter/test/kaos-acp.test.ts @@ -70,6 +70,7 @@ interface MockInnerKaos extends Kaos { getcwdCalls: number; chdirCalls: string[]; withCwdCalls: string[]; + withEnvCalls: Array>; statCalls: Array<{ path: string; options?: { followSymlinks?: boolean } }>; iterdirCalls: string[]; globCalls: Array<{ path: string; pattern: string; options?: { caseSensitive?: boolean } }>; @@ -91,6 +92,7 @@ function makeMockInner(opts?: { pathClass?: 'posix' | 'win32' }): MockInnerKaos getcwdCalls: 0, chdirCalls: [] as string[], withCwdCalls: [] as string[], + withEnvCalls: [] as Array>, statCalls: [] as Array<{ path: string; options?: { followSymlinks?: boolean } }>, iterdirCalls: [] as string[], globCalls: [] as Array<{ path: string; pattern: string; options?: { caseSensitive?: boolean } }>, @@ -132,6 +134,11 @@ function makeMockInner(opts?: { pathClass?: 'posix' | 'win32' }): MockInnerKaos const child = makeMockInner(); return child; }, + withEnv: (env: Record) => { + spy.withEnvCalls.push(env); + const child = makeMockInner(); + return child; + }, stat: async (path: string, options?: { followSymlinks?: boolean }) => { spy.statCalls.push({ path, options }); return { @@ -450,6 +457,24 @@ describe('AcpKaos', () => { }); }); + describe('withEnv', () => { + it('returns an AcpKaos that delegates env to inner and keeps the ACP bridge', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'BRIDGED' }), + }); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + const env = { FOO: 'bar' }; + const child = kaos.withEnv(env); + + expect(child).toBeInstanceOf(AcpKaos); + const text = await child.readText('/foo.ts'); + expect(text).toBe('BRIDGED'); + expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/foo.ts' }]); + expect(inner.__spy.withEnvCalls).toEqual([env]); + }); + }); + describe('pass-through delegation', () => { it('delegates pathClass, normpath, gethome, getcwd to inner', () => { const conn = makeMockConn({}); diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 22828b2ed..cbc041b6d 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -979,6 +979,7 @@ function createResumeNoSideEffectKaos(initialCwd: string): Kaos { gethome: () => '/home/test', getcwd: () => cwd, withCwd: (next: string) => createResumeNoSideEffectKaos(next), + withEnv: () => createResumeNoSideEffectKaos(cwd), chdir: async (next: string) => { cwd = next; }, diff --git a/packages/agent-core/test/tools/bash-env.test.ts b/packages/agent-core/test/tools/bash-env.test.ts index e88df9ee9..f5d59439e 100644 --- a/packages/agent-core/test/tools/bash-env.test.ts +++ b/packages/agent-core/test/tools/bash-env.test.ts @@ -64,4 +64,37 @@ describe('BashTool noninteractive env semantics', () => { if (previous !== undefined) process.env['GIT_TERMINAL_PROMPT'] = previous; } }); + + it('lets kaos-level env override BashTool env and observes in-place updates', async () => { + const execWithEnv = vi.fn().mockResolvedValue(fakeProcess()); + const sessionEnv = { + GIT_TERMINAL_PROMPT: 'configured', + KIMI_CODE_ENV: 'initial', + }; + const kaos = createFakeKaos({ execWithEnv, osEnv: posixEnv }).withEnv(sessionEnv); + const tool = new BashTool(kaos, '/workspace'); + + await executeTool(tool, { + turnId: '0', + toolCallId: 'tc_kaos_env_1', + args: { command: 'true', timeout: 1000 }, + signal, + }); + + const firstEnv = execWithEnv.mock.calls[0]?.[1] as Record; + expect(firstEnv['GIT_TERMINAL_PROMPT']).toBe('configured'); + expect(firstEnv['KIMI_CODE_ENV']).toBe('initial'); + + sessionEnv.KIMI_CODE_ENV = 'updated'; + await executeTool(tool, { + turnId: '0', + toolCallId: 'tc_kaos_env_2', + args: { command: 'true', timeout: 1000 }, + signal, + }); + + const secondEnv = execWithEnv.mock.calls[1]?.[1] as Record; + expect(secondEnv['GIT_TERMINAL_PROMPT']).toBe('configured'); + expect(secondEnv['KIMI_CODE_ENV']).toBe('updated'); + }); }); diff --git a/packages/agent-core/test/tools/fixtures/fake-kaos.ts b/packages/agent-core/test/tools/fixtures/fake-kaos.ts index 5a22938b8..78613d33a 100644 --- a/packages/agent-core/test/tools/fixtures/fake-kaos.ts +++ b/packages/agent-core/test/tools/fixtures/fake-kaos.ts @@ -28,7 +28,10 @@ export const FAKE_OS_ENV: Environment = { shellPath: '/bin/bash', }; -export function createFakeKaos(overrides?: Partial): Kaos { +export function createFakeKaos( + overrides?: Partial, + envLayers: readonly Record[] = [], +): Kaos { // Hold cwd in a closure so `chdir` (which `config.update({cwd})` now // routes through) can mutate it and later `getcwd()` calls see the // update — mirroring real-kaos semantics without needing a backing fs. @@ -40,7 +43,9 @@ export function createFakeKaos(overrides?: Partial): Kaos { normpath: (p: string) => p, gethome: () => '/home/test', getcwd: () => cwd, - withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }), + withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }, envLayers), + withEnv: (env: Record) => + createFakeKaos({ ...overrides, getcwd: () => cwd }, [...envLayers, env]), chdir: async (next: string) => { cwd = next; }, @@ -54,9 +59,31 @@ export function createFakeKaos(overrides?: Partial): Kaos { writeText: () => notImplemented('writeText'), mkdir: () => notImplemented('mkdir'), exec: () => notImplemented('exec'), - execWithEnv: () => notImplemented('execWithEnv'), + execWithEnv: (args, invocationEnv) => { + const mergedEnv = mergeEnvLayers(invocationEnv, envLayers); + if (overrides?.execWithEnv) return overrides.execWithEnv(args, mergedEnv); + return notImplemented('execWithEnv'); + }, }; - return { ...base, ...overrides } as Kaos; + return { + ...base, + ...overrides, + execWithEnv: base.execWithEnv, + withCwd: base.withCwd, + withEnv: base.withEnv, + } as Kaos; +} + +function mergeEnvLayers( + invocationEnv: Record | undefined, + envLayers: readonly Record[], +): Record | undefined { + if (envLayers.length === 0) return invocationEnv; + const merged: Record = { ...invocationEnv }; + for (const layer of envLayers) { + Object.assign(merged, layer); + } + return merged; } export const PERMISSIVE_WORKSPACE: WorkspaceConfig = { diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts index 8d16f771d..e674418ce 100644 --- a/packages/kaos/src/kaos.ts +++ b/packages/kaos/src/kaos.ts @@ -37,6 +37,13 @@ export interface Kaos { chdir(path: string): Promise; /** Return a new Kaos with the given `cwd`. */ withCwd(cwd: string): Kaos; + /** + * Return a new Kaos that overlays `env` onto every spawned process. + * + * The provided record is read when a process is spawned, so callers may + * mutate a stable record to update future executions. + */ + withEnv(env: Record): Kaos; /** Return stat metadata for `path`. */ stat(path: string, options?: { followSymlinks?: boolean }): Promise; /** Yield entry names in the directory at `path`. */ diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index f54bc026d..efe15573e 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -149,14 +149,20 @@ export class LocalKaos implements Kaos { readonly name: string = 'local'; readonly osEnv: Environment; private _cwd: string; + private readonly _envLayers: readonly Record[]; - private constructor(osEnv: Environment, cwd?: string) { + private constructor( + osEnv: Environment, + cwd?: string, + envLayers: readonly Record[] = [], + ) { // After construction we never touch `process.cwd()` / `process.chdir()` // — all path resolution goes through `this._cwd`. The default seeds // from `process.cwd()` but callers can pin to anything via `withCwd` // (or supplying `cwd` directly). this._cwd = normalize(cwd ?? process.cwd()); this.osEnv = osEnv; + this._envLayers = envLayers; } /** @@ -172,7 +178,11 @@ export class LocalKaos implements Kaos { } withCwd(cwd: string): LocalKaos { - return new LocalKaos(this.osEnv, cwd); + return new LocalKaos(this.osEnv, cwd, this._envLayers); + } + + withEnv(env: Record): LocalKaos { + return new LocalKaos(this.osEnv, this._cwd, [...this._envLayers, env]); } private _resolvePath(path: string): string { @@ -533,6 +543,7 @@ export class LocalKaos implements Kaos { // (`taskkill /T` handles the tree there). We do not call `child.unref()` // because the parent still waits on the child's exit through `wait()`. detached: !isWindows, + env: this._buildExecEnv(), }); await waitForSpawn(child); return new LocalProcess(child); @@ -550,11 +561,23 @@ export class LocalKaos implements Kaos { cwd: this._cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: !isWindows, - env, + env: this._buildExecEnv(env), }); await waitForSpawn(child); return new LocalProcess(child); } + + private _buildExecEnv(invocationEnv?: Record): Record | undefined { + if (this._envLayers.length === 0) return invocationEnv; + const merged: Record = { + ...(process.env as Record), + ...invocationEnv, + }; + for (const layer of this._envLayers) { + Object.assign(merged, layer); + } + return merged; + } } // Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts index 00b7eec0b..f9c4943ca 100644 --- a/packages/kaos/src/ssh.ts +++ b/packages/kaos/src/ssh.ts @@ -430,6 +430,7 @@ export class SSHKaos implements Kaos { private _sftp: SFTPWrapper; private _home: string; private _cwd: string; + private readonly _envLayers: readonly Record[]; // Stub: real wiring (probing the remote host via `uname` / `$SHELL` over the // SSH transport) is deferred. @@ -439,15 +440,26 @@ export class SSHKaos implements Kaos { ); } - private constructor(client: Client, sftp: SFTPWrapper, home: string, cwd: string) { + private constructor( + client: Client, + sftp: SFTPWrapper, + home: string, + cwd: string, + envLayers: readonly Record[] = [], + ) { this._client = client; this._sftp = sftp; this._home = home; this._cwd = cwd; + this._envLayers = envLayers; } withCwd(cwd: string): SSHKaos { - return new SSHKaos(this._client, this._sftp, this._home, cwd); + return new SSHKaos(this._client, this._sftp, this._home, cwd, this._envLayers); + } + + withEnv(env: Record): SSHKaos { + return new SSHKaos(this._client, this._sftp, this._home, this._cwd, [...this._envLayers, env]); } private _resolvePath(path: string): string { @@ -834,7 +846,7 @@ export class SSHKaos implements Kaos { 'SSHKaos.exec(): at least one argument (the command to run) is required.', ); } - return this._execInternal(args); + return this._execInternal(args, this._buildExecEnv()); } execWithEnv(args: string[], env?: Record): Promise { @@ -843,7 +855,16 @@ export class SSHKaos implements Kaos { 'SSHKaos.execWithEnv(): at least one argument (the command to run) is required.', ); } - return this._execInternal(args, env); + return this._execInternal(args, this._buildExecEnv(env)); + } + + private _buildExecEnv(invocationEnv?: Record): Record | undefined { + if (this._envLayers.length === 0) return invocationEnv; + const merged: Record = { ...invocationEnv }; + for (const layer of this._envLayers) { + Object.assign(merged, layer); + } + return merged; } /** diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts index 01d7e4328..e5076cb3f 100644 --- a/packages/kaos/test/local.test.ts +++ b/packages/kaos/test/local.test.ts @@ -640,6 +640,35 @@ describe('LocalKaos', () => { expect(exitCode).not.toBe(0); }); }); + + describe('withEnv', () => { + it('overlays every spawned process and can be updated in place', async () => { + const env = { + KAOS_BASE_ENV: 'initial', + KAOS_COLLISION_ENV: 'configured', + }; + const envKaos = kaos.withEnv(env); + const printEnv = + 'process.stdout.write(`${process.env.KAOS_BASE_ENV}|${process.env.KAOS_COLLISION_ENV}|${process.env.KAOS_CALL_ENV}`)'; + + const first = await envKaos.exec('node', '-e', printEnv); + expect(await first.wait()).toBe(0); + expect((await streamToBuffer(first.stdout)).toString('utf-8')).toBe('initial|configured|undefined'); + + const second = await envKaos.execWithEnv(['node', '-e', printEnv], { + ...(process.env as Record), + KAOS_COLLISION_ENV: 'host', + KAOS_CALL_ENV: 'call', + }); + expect(await second.wait()).toBe(0); + expect((await streamToBuffer(second.stdout)).toString('utf-8')).toBe('initial|configured|call'); + + env.KAOS_BASE_ENV = 'updated'; + const third = await envKaos.exec('node', '-e', printEnv); + expect(await third.wait()).toBe(0); + expect((await streamToBuffer(third.stdout)).toString('utf-8')).toBe('updated|configured|undefined'); + }); + }); }); describe('LocalKaos instance isolation', () => { diff --git a/packages/kaos/test/ssh.test.ts b/packages/kaos/test/ssh.test.ts index 5b9e69b39..e506e3f1f 100644 --- a/packages/kaos/test/ssh.test.ts +++ b/packages/kaos/test/ssh.test.ts @@ -1280,10 +1280,16 @@ describe('SSHKaos mock success paths', () => { }, }; const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _client: unknown; _cwd: string; _home: string }; + const internal = instance as unknown as { + _client: unknown; + _cwd: string; + _home: string; + _envLayers: readonly Record[]; + }; internal._client = fakeClient; internal._cwd = '/home/tester'; internal._home = '/home/tester'; + internal._envLayers = []; await expect(instance.execWithEnv(['echo', 'hi'], { FOO: 'bar' })).rejects.toThrow('stop'); @@ -1442,10 +1448,12 @@ describe('SSHKaos.close lifecycle', () => { _cwd: string; _home: string; _sftp: { end(): void }; + _envLayers: readonly Record[]; }; internals._client = new FakeClient(); internals._cwd = '/tmp'; internals._home = '/tmp'; + internals._envLayers = []; internals._sftp = { end(): void { // no-op From 588cdaa152d8b71f252f7ce56a14a448b34d21f6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 11 Jun 2026 17:21:42 +0800 Subject: [PATCH 041/476] chore: remove pnpm catalog usage (#653) --- packages/agent-core/package.json | 2 +- packages/kosong/package.json | 2 +- packages/migration-legacy/package.json | 2 +- packages/node-sdk/package.json | 2 +- packages/protocol/package.json | 2 +- pnpm-lock.yaml | 16 +++++----------- pnpm-workspace.yaml | 3 --- 7 files changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index a6649cec8..427150c40 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -76,7 +76,7 @@ "tar": "^7.5.13", "undici": "^7.27.1", "yauzl": "^3.3.0", - "zod": "catalog:" + "zod": "^4.3.6" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/kosong/package.json b/packages/kosong/package.json index b0716dc0c..15063979c 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -49,7 +49,7 @@ "@anthropic-ai/sdk": "^0.95.2", "@google/genai": "^1.49.0", "openai": "^6.34.0", - "zod": "catalog:", + "zod": "^4.3.6", "zod-to-json-schema": "^3.25.2" }, "devDependencies": { diff --git a/packages/migration-legacy/package.json b/packages/migration-legacy/package.json index 1c24d2983..d95b028fb 100644 --- a/packages/migration-legacy/package.json +++ b/packages/migration-legacy/package.json @@ -29,7 +29,7 @@ "dependencies": { "@moonshot-ai/agent-core": "workspace:^", "smol-toml": "^1.6.1", - "zod": "catalog:" + "zod": "^4.3.6" }, "devDependencies": { "@moonshot-ai/kaos": "workspace:^" diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index efa9bd322..12ea3735b 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -59,7 +59,7 @@ "@antfu/utils": "^9.3.0", "smol-toml": "^1.6.1", "yazl": "^3.3.1", - "zod": "catalog:" + "zod": "^4.3.6" }, "devDependencies": { "@moonshot-ai/agent-core": "workspace:^", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 498aa4707..34f904220 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -34,6 +34,6 @@ }, "dependencies": { "ulid": "^3.0.1", - "zod": "catalog:" + "zod": "^4.3.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f2ea795..13fa0c2a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,12 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - zod: - specifier: ^4.3.6 - version: 4.3.6 - overrides: ssh2@1.17.0>cpu-features: '-' ssh2@1.17.0>nan: '-' @@ -296,7 +290,7 @@ importers: specifier: ^3.3.0 version: 3.3.0 zod: - specifier: 'catalog:' + specifier: ^4.3.6 version: 4.3.6 devDependencies: '@types/js-yaml': @@ -355,7 +349,7 @@ importers: specifier: ^6.34.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) zod: - specifier: 'catalog:' + specifier: ^4.3.6 version: 4.3.6 zod-to-json-schema: specifier: ^3.25.2 @@ -377,7 +371,7 @@ importers: specifier: ^1.6.1 version: 1.6.1 zod: - specifier: 'catalog:' + specifier: ^4.3.6 version: 4.3.6 devDependencies: '@moonshot-ai/kaos': @@ -396,7 +390,7 @@ importers: specifier: ^3.3.1 version: 3.3.1 zod: - specifier: 'catalog:' + specifier: ^4.3.6 version: 4.3.6 devDependencies: '@moonshot-ai/agent-core': @@ -431,7 +425,7 @@ importers: specifier: ^3.0.1 version: 3.0.2 zod: - specifier: 'catalog:' + specifier: ^4.3.6 version: 4.3.6 packages/telemetry: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c486c305b..610c96158 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,9 +5,6 @@ packages: - apps/vis/web - docs -catalog: - zod: ^4.3.6 - overrides: "ssh2@1.17.0>cpu-features": "-" "ssh2@1.17.0>nan": "-" From 0381329570d3dca9fd861761c843968cc1c5e927 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Thu, 11 Jun 2026 19:32:26 +0800 Subject: [PATCH 042/476] fix: send responses system prompts as instructions (#658) --- .changeset/openai-responses-instructions.md | 6 ++++ .../kosong/src/providers/openai-responses.ts | 10 ++---- .../e2e/openai-responses-adapter-e2e.test.ts | 2 +- packages/kosong/test/openai-responses.test.ts | 32 ++++++++++++------- 4 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 .changeset/openai-responses-instructions.md diff --git a/.changeset/openai-responses-instructions.md b/.changeset/openai-responses-instructions.md new file mode 100644 index 000000000..32bf57264 --- /dev/null +++ b/.changeset/openai-responses-instructions.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +--- + +Send OpenAI Responses system prompts as request instructions. diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index b3804f025..3595e2105 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -1040,13 +1040,6 @@ export class OpenAIResponsesChatProvider implements ChatProvider { options?: GenerateOptions, ): Promise { const input: unknown[] = []; - if (systemPrompt) { - const sysItem: Record = { role: 'system', content: systemPrompt }; - if (usesOpenAIResponsesDeveloperRole(this._model)) { - sysItem['role'] = 'developer'; - } - input.push(sysItem); - } const normalizedHistory = normalizeToolCallIdsForProvider( history, @@ -1086,6 +1079,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { stream: this._stream, ...kwargs, }; + if (systemPrompt) { + createParams['instructions'] = systemPrompt; + } if ( !('responses' in client) || diff --git a/packages/kosong/test/e2e/openai-responses-adapter-e2e.test.ts b/packages/kosong/test/e2e/openai-responses-adapter-e2e.test.ts index e2d18efac..5d0dd14e2 100644 --- a/packages/kosong/test/e2e/openai-responses-adapter-e2e.test.ts +++ b/packages/kosong/test/e2e/openai-responses-adapter-e2e.test.ts @@ -113,8 +113,8 @@ describe('e2e: openai-responses adapter', () => { model: 'gpt-4.1', stream: false, store: false, + instructions: 'You are helpful.', input: [ - { role: 'developer', content: 'You are helpful.' }, { role: 'user', type: 'message', diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 3e95d9152..5946b6309 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -96,16 +96,15 @@ const MUL_TOOL: Tool = { describe('OpenAIResponsesChatProvider', () => { describe('message conversion', () => { - it('simple user message with system prompt', async () => { + it('sends system prompt as top-level instructions', async () => { const provider = createProvider(); const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hello!' }], toolCalls: [] }, ]; const body = await captureRequestBody(provider, 'You are helpful.', [], history); + expect(body['instructions']).toBe('You are helpful.'); expect(body['input']).toEqual([ - // gpt-4.1 is an OpenAI model -> developer role - { role: 'developer', content: 'You are helpful.' }, { content: [{ type: 'input_text', text: 'Hello!' }], role: 'user', @@ -124,6 +123,7 @@ describe('OpenAIResponsesChatProvider', () => { ]; const body = await captureRequestBody(provider, '', [], history); + expect(body).not.toHaveProperty('instructions'); expect(body['input']).toEqual([ { content: [{ type: 'input_text', text: 'What is 2+2?' }], @@ -152,8 +152,8 @@ describe('OpenAIResponsesChatProvider', () => { ]; const body = await captureRequestBody(provider, 'You are a math tutor.', [], history); + expect(body['instructions']).toBe('You are a math tutor.'); expect(body['input']).toEqual([ - { role: 'developer', content: 'You are a math tutor.' }, { content: [{ type: 'input_text', text: 'What is 2+2?' }], role: 'user', @@ -202,19 +202,24 @@ describe('OpenAIResponsesChatProvider', () => { ]); }); - it('OpenAI model name with date suffix matches the partial-prefix branch', async () => { - // gpt-4.1-2025-04-14 should be recognized as gpt-4.1, mapping system → developer. + it('OpenAI model name with date suffix maps history system message to developer', async () => { + // gpt-4.1-2025-04-14 should be recognized as gpt-4.1 for history messages. const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1-2025-04-14', apiKey: 'test-key', }); const history: Message[] = [ + { role: 'system', content: [{ type: 'text', text: 'Remember this.' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, ]; - const body = await captureRequestBody(provider, 'You are helpful.', [], history); + const body = await captureRequestBody(provider, '', [], history); expect(body['input']).toEqual([ - { role: 'developer', content: 'You are helpful.' }, + { + content: [{ type: 'input_text', text: 'Remember this.' }], + role: 'developer', + type: 'message', + }, { content: [{ type: 'input_text', text: 'hi' }], role: 'user', @@ -223,18 +228,23 @@ describe('OpenAIResponsesChatProvider', () => { ]); }); - it('non-OpenAI model name keeps system role unchanged', async () => { + it('non-OpenAI model name keeps history system role unchanged', async () => { const provider = new OpenAIResponsesChatProvider({ model: 'some-other-model', apiKey: 'test-key', }); const history: Message[] = [ + { role: 'system', content: [{ type: 'text', text: 'Remember this.' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, ]; - const body = await captureRequestBody(provider, 'You are helpful.', [], history); + const body = await captureRequestBody(provider, '', [], history); const input = body['input'] as Array>; - expect(input[0]).toEqual({ role: 'system', content: 'You are helpful.' }); + expect(input[0]).toEqual({ + content: [{ type: 'input_text', text: 'Remember this.' }], + role: 'system', + type: 'message', + }); }); it('user message with audio_url data URL (mp3) is encoded as input_file with base64', async () => { From 0927f79883e036d0127d4384f60f8e486afb3b8c Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Thu, 11 Jun 2026 20:52:50 +0800 Subject: [PATCH 043/476] fix: cancel active turns during session shutdown (#661) * fix: cancel active turns during session shutdown * fix: preserve background agents during session close --- .changeset/cancel-active-turns-on-close.md | 6 + apps/kimi-code/src/cli/run-prompt.ts | 7 +- apps/kimi-code/test/cli/run-prompt.test.ts | 41 +++++ packages/agent-core/src/session/index.ts | 76 +++++++++ .../src/tools/builtin/shell/bash.ts | 3 +- packages/agent-core/src/utils/abort.ts | 4 +- .../test/session/lifecycle-hooks.test.ts | 150 +++++++++++++++++- packages/agent-core/test/utils/abort.test.ts | 7 + 8 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 .changeset/cancel-active-turns-on-close.md diff --git a/.changeset/cancel-active-turns-on-close.md b/.changeset/cancel-active-turns-on-close.md new file mode 100644 index 000000000..b6095915c --- /dev/null +++ b/.changeset/cancel-active-turns-on-close.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index d151c2824..d5253531e 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -353,16 +353,21 @@ function installPromptTerminationCleanup( }; const onSigint = () => exitAfterCleanup('SIGINT'); const onSigterm = () => exitAfterCleanup('SIGTERM'); + const onSighup = () => exitAfterCleanup('SIGHUP'); promptProcess.once('SIGINT', onSigint); promptProcess.once('SIGTERM', onSigterm); + promptProcess.once('SIGHUP', onSighup); return () => { promptProcess.off('SIGINT', onSigint); promptProcess.off('SIGTERM', onSigterm); + promptProcess.off('SIGHUP', onSighup); }; } function signalExitCode(signal: NodeJS.Signals): number { - return signal === 'SIGINT' ? 130 : 143; + if (signal === 'SIGINT') return 130; + if (signal === 'SIGHUP') return 129; + return 143; } function runPromptTurn( diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index e4a2d2149..9913dd926 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -656,6 +656,47 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); + it.each([ + ['SIGTERM' as NodeJS.Signals, 143], + ['SIGHUP' as NodeJS.Signals, 129], + ])('cleans up prompt mode before exiting on %s', async (signal, exitCode) => { + let releasePrompt!: () => void; + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 7, origin: { kind: 'user' } }), + ); + } + await new Promise((resolve) => { + releasePrompt = resolve; + }); + }); + const processMock = fakeProcess(); + const run = runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + process: processMock, + } as Parameters[2] & { process: ReturnType }); + + await waitForAssertion(() => { + expect(processMock.listener(signal)).toBeDefined(); + }); + + await processMock.listener(signal)?.(); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + expect(processMock.exit).toHaveBeenCalledWith(exitCode); + + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 7, reason: 'completed' })); + } + releasePrompt(); + await run; + + expect(mocks.harnessClose).toHaveBeenCalledTimes(1); + }); + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; let releasePrompt!: () => void; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 99efe086b..851ac58af 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -40,6 +40,7 @@ import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; import { SessionSubagentHost } from './subagent-host'; import type { ToolServices } from '../tools/support/services'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; +import { abortError } from '../utils/abort'; export interface SessionOptions { readonly kaos: Kaos; @@ -107,6 +108,32 @@ export interface SessionMeta { } const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; +const ACTIVE_TURN_CLOSE_TIMEOUT_MS = 8_000; + +async function waitForSettlementOrTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(false); + }, timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} export class Session { readonly rpc: SDKSessionRPC; @@ -232,6 +259,7 @@ export class Session { await Promise.allSettled( Array.from(this.readyAgents(), async (agent) => agent.cron?.stop()), ); + await this.cancelActiveTurnsOnClose(); await this.stopBackgroundTasksOnExit(); await this.flushMetadata(); await this.triggerSessionEnd('exit'); @@ -259,6 +287,54 @@ export class Session { } } + private async cancelActiveTurnsOnClose(): Promise { + const backgroundAgentIds = this.activeBackgroundAgentIds(); + const cancellations: Array> = []; + for (const [agentId, entry] of this.agents) { + if (!(entry instanceof Agent) || backgroundAgentIds.has(agentId)) continue; + cancellations.push(this.cancelAgentTurnOnClose(entry)); + } + await Promise.allSettled(cancellations); + } + + private activeBackgroundAgentIds(): Set { + const agentIds = new Set(); + for (const agent of this.readyAgents()) { + for (const task of agent.background.list(true)) { + if (task.kind === 'agent' && task.agentId !== undefined) { + agentIds.add(task.agentId); + } + } + } + return agentIds; + } + + private async cancelAgentTurnOnClose(agent: Agent): Promise { + if (!agent.turn.hasActiveTurn) return; + + let waitForTurn: Promise; + try { + waitForTurn = agent.turn.waitForCurrentTurn(); + } catch (error: unknown) { + this.log.debug('active turn wait unavailable during session close', { + agentType: agent.type, + agentHomedir: agent.homedir, + error, + }); + return; + } + + agent.turn.cancel(undefined, abortError('Session closed')); + const settled = await waitForSettlementOrTimeout(waitForTurn, ACTIVE_TURN_CLOSE_TIMEOUT_MS); + if (!settled) { + this.log.warn('timed out waiting for active turn to cancel during session close', { + agentType: agent.type, + agentHomedir: agent.homedir, + timeoutMs: ACTIVE_TURN_CLOSE_TIMEOUT_MS, + }); + } + } + private async stopBackgroundTasksOnExit(): Promise { const keepAliveOnExit = resolveConfigValue({ env: process.env, diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index b6e0c6e86..a176678e8 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -405,7 +405,7 @@ export class BashTool implements BuiltinTool { } if (timeoutMs !== undefined) { - setTimeout(() => { + const timeoutHandle = setTimeout(() => { void (async (): Promise => { if (proc.exitCode !== null) return; const info = backgroundManager.getTask(taskId); @@ -414,6 +414,7 @@ export class BashTool implements BuiltinTool { } })(); }, timeoutMs); + timeoutHandle.unref?.(); } // registerTask() synchronously inserts taskId into the manager's Map, so diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/utils/abort.ts index 6ef6d433b..d452f0079 100644 --- a/packages/agent-core/src/utils/abort.ts +++ b/packages/agent-core/src/utils/abort.ts @@ -1,5 +1,5 @@ -export function abortError(): Error { - const error = new Error('Aborted'); +export function abortError(message = 'Aborted'): Error { + const error = new Error(message); error.name = 'AbortError'; return error; } diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index aa63eb4c4..a92df206b 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; -import { ProcessBackgroundTask } from '../../src/agent/background'; +import { AgentBackgroundTask, ProcessBackgroundTask } from '../../src/agent/background'; const tempDirs: string[] = []; @@ -153,6 +153,46 @@ describe('Session lifecycle hooks', () => { expect(agent.background.getTask(taskId)?.status).toBe('running'); }); + it('keeps background agent turns alive on close when keepAliveOnExit is true', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-bg-agent-keepalive', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + background: { keepAliveOnExit: true }, + }); + const main = await session.createMain(); + const { id: childId, agent: child } = await session.createAgent( + { type: 'sub' }, + { parentAgentId: 'main' }, + ); + const turnSettled = createDeferred(); + const waitSpy = vi + .spyOn(child.turn, 'waitForCurrentTurn') + .mockImplementation(() => turnSettled.promise as never); + const cancelSpy = vi.spyOn(child.turn, 'cancel').mockImplementation(() => { + turnSettled.resolve(); + }); + vi.spyOn(child.turn, 'hasActiveTurn', 'get').mockReturnValue(true); + const abort = vi.fn(); + const taskId = main.background.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'keep background agent alive', { + abort, + agentId: childId, + subagentType: 'coder', + }), + ); + + await session.close(); + + expect(cancelSpy).not.toHaveBeenCalled(); + expect(waitSpy).not.toHaveBeenCalled(); + expect(abort).not.toHaveBeenCalled(); + expect(main.background.getTask(taskId)?.status).toBe('running'); + }); + it('lets the environment override config when deciding background task cleanup', async () => { vi.stubEnv('KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT', '0'); const { sessionDir, workDir } = await hookFixture(); @@ -176,6 +216,81 @@ describe('Session lifecycle hooks', () => { expect(agent.background.getTask(taskId)?.status).toBe('killed'); }); + it('cancels an active foreground turn before closing', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-active-turn-cleanup', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + const agent = await session.createMain(); + const turnSettled = createDeferred(); + const waitSpy = vi + .spyOn(agent.turn, 'waitForCurrentTurn') + .mockImplementation(() => turnSettled.promise as never); + const cancelSpy = vi.spyOn(agent.turn, 'cancel').mockImplementation(() => { + turnSettled.resolve(); + }); + vi.spyOn(agent.turn, 'hasActiveTurn', 'get').mockReturnValue(true); + + await session.close(); + + expect(cancelSpy).toHaveBeenCalledWith(undefined, expect.any(Error)); + expect(waitSpy).toHaveBeenCalledOnce(); + }); + + it('records session-close cancellation during UserPromptSubmit hooks as cancelled', async () => { + const { sessionDir, workDir } = await hookFixture(); + const hookStartedPath = join(workDir, 'hook-started'); + const hookScriptPath = join(workDir, 'blocking-user-prompt-hook.cjs'); + await writeFile( + hookScriptPath, + [ + "const { writeFileSync } = require('node:fs');", + "writeFileSync(process.argv[2], 'started');", + 'setInterval(() => {}, 1000);', + '', + ].join('\n'), + 'utf-8', + ); + const emitEvent = vi.fn(async () => {}); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-close-during-user-hook', + homedir: sessionDir, + rpc: createSessionRpc({ emitEvent }), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + hooks: [ + { + event: 'UserPromptSubmit', + command: `node ${JSON.stringify(hookScriptPath)} ${JSON.stringify(hookStartedPath)}`, + timeout: 30, + }, + ], + }); + const agent = await session.createMain(); + + agent.turn.prompt([{ type: 'text', text: 'run the hook' }]); + await waitForFile(hookStartedPath); + await session.close(); + + const events = emitEvent.mock.calls.map(([event]) => event); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'cancelled', + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'failed', + }), + ); + }); + it('keeps background tasks alive and skips SessionEnd hooks when closing for reload', async () => { const { command, logPath, sessionDir, workDir } = await hookFixture(); const session = new Session({ @@ -260,7 +375,7 @@ async function readHookPayloads(path: string): Promise JSON.parse(line) as Record); } -function createSessionRpc(): SDKSessionRPC { +function createSessionRpc(overrides: Partial = {}): SDKSessionRPC { return { emitEvent: vi.fn(async () => {}), requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), @@ -269,9 +384,40 @@ function createSessionRpc(): SDKSessionRPC { output: 'custom tools are not supported in this test', isError: true, })), + ...overrides, } as SDKSessionRPC; } +async function waitForFile(path: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + await readFile(path, 'utf-8'); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw lastError; +} + +function createDeferred(): { + readonly promise: Promise; + resolve(value: T): void; +} { + let resolveValue: (value: T) => void = () => { + /* replaced below */ + }; + const promise = new Promise((resolve) => { + resolveValue = resolve; + }); + return { + promise, + resolve: resolveValue, + }; +} + function pendingProcess(exitOnKill = 143): { readonly proc: KaosProcess; readonly killSpy: ReturnType; diff --git a/packages/agent-core/test/utils/abort.test.ts b/packages/agent-core/test/utils/abort.test.ts index 2f0699fbc..56f7881bc 100644 --- a/packages/agent-core/test/utils/abort.test.ts +++ b/packages/agent-core/test/utils/abort.test.ts @@ -25,6 +25,13 @@ describe('userCancellationReason', () => { expect(isUserCancellation(new Error('boom'))).toBe(false); expect(isUserCancellation(undefined)).toBe(false); }); + + it('keeps custom system abort messages classified as AbortError', () => { + expect(abortError('Session closed')).toMatchObject({ + name: 'AbortError', + message: 'Session closed', + }); + }); }); describe('abortable', () => { From 1e2e679693af2fc97826078aa671555a3a900349 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 11 Jun 2026 22:20:31 +0800 Subject: [PATCH 044/476] feat: add tips banner below welcome panel on startup (#655) * chore: ignore .worktrees directory * feat: add tips banner below welcome panel on startup - Fetch active/fallback tips from the configured CDN with a 3s timeout. - Filter tips by semver, client version, and date range. - Render the banner directly below the welcome panel on startup/resume. - Support tag, multi-line text, subtext, automatic wrapping, and narrow-terminal safety. * refactor(tui): use theme color methods in banner component Replace raw chalk calls with currentTheme helpers: tag uses boldFg('primary'), main text uses boldFg('textStrong'), and subtext uses fg('textDim') without stacking the dim modifier on the already dim shade. Strengthen tests to assert the exact themed ANSI output. --- .changeset/tips-banner.md | 5 + .gitignore | 1 + apps/kimi-code/src/constant/app.ts | 1 + .../src/tui/banner/banner-provider.ts | 146 ++++++++++++ .../src/tui/components/chrome/banner.ts | 76 ++++++ apps/kimi-code/src/tui/kimi-tui.ts | 36 +++ apps/kimi-code/src/tui/types.ts | 8 + .../test/tui/banner/banner-provider.test.ts | 217 ++++++++++++++++++ .../test/tui/components/chrome/banner.test.ts | 193 ++++++++++++++++ .../test/tui/kimi-tui-startup.test.ts | 45 ++++ 10 files changed, 728 insertions(+) create mode 100644 .changeset/tips-banner.md create mode 100644 apps/kimi-code/src/tui/banner/banner-provider.ts create mode 100644 apps/kimi-code/src/tui/components/chrome/banner.ts create mode 100644 apps/kimi-code/test/tui/banner/banner-provider.test.ts create mode 100644 apps/kimi-code/test/tui/components/chrome/banner.test.ts diff --git a/.changeset/tips-banner.md b/.changeset/tips-banner.md new file mode 100644 index 000000000..2e47cc18a --- /dev/null +++ b/.changeset/tips-banner.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Display a tips banner below the welcome panel on startup. diff --git a/.gitignore b/.gitignore index 3fdbd2d60..691c675b7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage/ .kimi-stash-dir plugins/cdn/ superpowers +.worktrees/ diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 535ec138e..623f7e715 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -45,6 +45,7 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts new file mode 100644 index 000000000..c9707e2f7 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -0,0 +1,146 @@ +import { gte, valid } from 'semver'; + +import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; +import type { BannerState } from '#/tui/types'; + +interface TipsBannerFallbackItem { + enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_min_version?: string | null; +} + +interface TipsBannerJson { + banner_enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_min_version?: string | null; + banner_fallback_enabled?: boolean; + banner_fallback_list?: unknown[]; +} + +function normalizeTag(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeUtcDate(value: string): string { + if (value.endsWith('Z')) return value; + if (/[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.length === 0) return null; + const normalized = normalizeUtcDate(value); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean { + if (start !== null && now < start) return false; + if (end !== null && now > end) return false; + return true; +} + +function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { + if (minVersion === undefined || minVersion === null) return true; + if (typeof minVersion !== 'string' || minVersion.length === 0) return true; + const min = valid(minVersion); + const current = valid(clientVersion); + if (min === null || current === null) return false; + return gte(current, min); +} + +function pickActiveBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState | null { + if (json.banner_enabled !== true) return null; + if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + const start = parseDate(json.banner_start_time); + const end = parseDate(json.banner_end_time); + if (!isWithinWindow(start, end, now)) return null; + const mainText = normalizeText(json.banner_maintext); + if (mainText === null) return null; + return { + tag: normalizeTag(json.banner_title), + mainText, + subText: normalizeText(json.banner_subtext), + }; +} + +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + if (json.banner_fallback_enabled !== true) return null; + const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; + const candidates: BannerState[] = []; + for (const raw of list) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as TipsBannerFallbackItem; + if (item.enabled !== true) continue; + if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + const mainText = normalizeText(item.banner_maintext); + if (mainText === null) continue; + candidates.push({ + tag: normalizeTag(item.banner_title), + mainText, + subText: normalizeText(item.banner_subtext), + }); + } + if (candidates.length === 0) return null; + const index = Math.floor(random() * candidates.length); + return candidates[index]!; +} + +export function selectBannerState( + json: unknown, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + return ( + pickActiveBanner(typed, clientVersion, now) ?? + pickFallbackBanner(typed, clientVersion, now, random) + ); +} + +export class BannerProvider { + constructor( + private readonly clientVersion: string, + private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, + ) {} + + async load(fetchImpl: typeof fetch = fetch): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + const response = await fetchImpl(this.url, { signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return null; + const json = await response.json(); + return selectBannerState(json, this.clientVersion, new Date(), Math.random); + } catch { + return null; + } + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts new file mode 100644 index 000000000..265f0b02e --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -0,0 +1,76 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const PREFIX_STAR = '✦'; +const PADDING = ' '; + +export class BannerComponent implements Component { + constructor(private readonly state: BannerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const main = (s: string): string => currentTheme.boldFg('textStrong', s); + const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Render nothing but the trailing blank if the terminal cannot hold a + // single visible column. + if (width < 1) { + return ['']; + } + + const tagText = this.state.tag ?? ''; + // Do not add a colon/tag suffix here; the caller-provided tag includes its + // own punctuation/separator. + const tagLabel = tagText.length > 0 ? `${PREFIX_STAR} ${tagText}` : ''; + const tagStyled = tagLabel.length > 0 ? currentTheme.boldFg('primary', tagLabel) : ''; + const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; + const tagWidth = visibleWidth(tagDisplay); + const showTag = tagWidth > 0 && tagWidth < width; + // Body lines (continuations of the main text) indent to match the first + // line's main-text column, which starts right after the tag display. + const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // Descriptive subtext lines (the second line in the design) start at the + // column after the leading star + space, aligning with the tag text itself. + const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; + const bodyContentWidth = width - (showTag ? tagWidth : 0); + const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + + if (bodyContentWidth <= 0) { + return ['']; + } + + const mainSegments = this.state.mainText.split('\n'); + const subSegments = this.state.subText ? this.state.subText.split('\n') : []; + + const result: string[] = []; + for (let i = 0; i < mainSegments.length; i++) { + const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); + for (let j = 0; j < wrapped.length; j++) { + const boldLine = main(wrapped[j]!); + if (i === 0 && j === 0 && showTag) { + result.push(tagDisplay + boldLine); + } else { + result.push(bodyIndent + boldLine); + } + } + } + + for (const sub of subSegments) { + const available = descContentWidth <= 0 ? bodyContentWidth : descContentWidth; + const wrapped = wrapTextWithAnsi(sub, available); + for (const line of wrapped) { + result.push(descIndent + dim(line)); + } + } + + // Add a blank line below the banner so the following transcript content + // (e.g. the input prompt / status messages) is visually separated. + result.push(''); + + return result; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dcddb8987..2eb00d2c6 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -97,6 +97,8 @@ import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; +import { BannerProvider } from './banner/banner-provider'; +import { BannerComponent } from './components/chrome/banner'; import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; @@ -183,6 +185,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionTitle: null, goal: null, mcpServersSummary: null, + banner: undefined, }; } @@ -408,12 +411,45 @@ export class KimiTUI { } } + private async loadBanner(): Promise { + const provider = new BannerProvider(this.state.appState.version); + this.state.appState.banner = await provider.load(); + if (this.state.appState.banner !== null) { + this.renderBanner(); + this.state.ui.requestRender(); + } + } + + private renderBanner(): void { + if (this.state.appState.banner === null || this.state.appState.banner === undefined) { + return; + } + if ( + this.state.transcriptContainer.children.some( + (child) => child instanceof BannerComponent, + ) + ) { + return; + } + const welcomeIndex = this.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const banner = new BannerComponent(this.state.appState.banner); + if (welcomeIndex >= 0) { + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); + } else { + this.state.transcriptContainer.children.unshift(banner); + } + this.state.transcriptContainer.invalidate(); + } + private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); + void this.loadBanner(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 8bf127096..836db9311 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -12,6 +12,12 @@ import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; +export interface BannerState { + tag: string | null; + mainText: string; + subText: string | null; +} + export interface AppState { model: string; workDir: string; @@ -38,6 +44,8 @@ export interface AppState { /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; mcpServersSummary: string | null; + /** Optional banner shown below the welcome panel; null means no banner to render. */ + banner?: BannerState | null; } export interface ToolCallBlockData { diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts new file mode 100644 index 000000000..a2036ab31 --- /dev/null +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { selectBannerState } from '#/tui/banner/banner-provider'; + +describe('selectBannerState', () => { + const now = new Date('2026-06-15T12:00:00+08:00'); + + it('returns the active banner when enabled and no time window is set', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: 'New', mainText: 'Active', subText: 'Details' }); + }); + + it('returns null when the active banner is outside its time window', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'Old', + banner_maintext: 'Expired', + banner_start_time: '2026-05-01T00:00:00+08:00', + banner_end_time: '2026-05-31T00:00:00+08:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('filters out the active banner when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'New', + banner_min_version: '0.15.0', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('picks a random enabled fallback when the active banner is not shown', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_title: 'Tip', banner_maintext: 'First' }, + { enabled: true, banner_title: 'Tip', banner_maintext: 'Second' }, + ], + }, + '0.14.0', + now, + () => 0.75, + ); + expect(result).toEqual({ tag: 'Tip', mainText: 'Second', subText: null }); + }); + + it('filters out fallback entries when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_maintext: 'Old tip' }, + { enabled: true, banner_maintext: 'New tip', banner_min_version: '0.15.0' }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Old tip', subText: null }); + }); + + it('returns null when no enabled fallback entries exist', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: false, banner_maintext: 'Hidden' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null for malformed input fields', () => { + expect(selectBannerState({ weird: true }, '0.14.0', now, () => 0)).toBeNull(); + }); + + it('falls back to the fallback list when banner_enabled is missing', () => { + const result = selectBannerState( + { + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats an empty tag as null while still showing the banner', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: '', + banner_maintext: 'No tag', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'No tag', subText: null }); + }); + + it('makes the active banner unavailable when mainText is empty', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: '', + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats missing subtext as null', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Main only', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Main only', subText: null }); + }); + + it('treats empty time fields as always valid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Always on', + banner_start_time: '', + banner_end_time: null, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Always on', subText: null }); + }); + + it('falls back to UTC when timestamps have no timezone', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'UTC fallback', + banner_start_time: '2026-06-15T04:00:00', + banner_end_time: '2026-06-15T20:00:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'UTC fallback', subText: null }); + }); + + it('returns null when the fallback list is empty', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null when the fallback list is missing', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts new file mode 100644 index 000000000..04827fb80 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -0,0 +1,193 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@earendil-works/pi-tui'; + +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const banner: BannerState = { + tag: "What's new:", + mainText: 'This is the main banner message for testing purposes.', + subText: 'This is a short subtext line.', +}; + +describe('BannerComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders star tag, main text, and subtext', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + expect(lines[0]).toContain('This is the main banner message'); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[2]).toBe(''); + }); + + it('does not add an extra colon to the tag', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).not.toContain("What's new::"); + }); + + it('renders without a tag when tag is empty', () => { + const lines = new BannerComponent({ tag: null, mainText: 'Hello', subText: null }).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).toContain('Hello'); + expect(lines[1]).toBe(''); + }); + + it('wraps long main text to fit available width', () => { + const width = 30; + const lines = new BannerComponent(banner).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.some((line) => line.includes('…'))).toBe(false); + const mainContentLines = lines.filter((line) => + /main|banner|message|testing|purposes/.test(line), + ); + expect(mainContentLines.length).toBeGreaterThan(1); + }); + + it('wraps long subtext to fit available width', () => { + const width = 30; + const state: BannerState = { + tag: null, + mainText: 'Short', + subText: 'Short subtext line one plus subtext line two for wrapping tests.', + }; + const lines = new BannerComponent(state).render(width); + expect(lines[0]).toContain('Short'); + const subContentLines = lines.filter((line) => + /Short subtext|line one|plus|subtext|line two|for|wrapping|tests/.test(line), + ); + expect(subContentLines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('keeps every line within terminal width on very narrow terminals', () => { + for (const width of [0, 1, 2, 3, 5, 10]) { + const lines = new BannerComponent(banner).render(width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); + + it('shows tag only on the first wrapped line', () => { + const width = 40; + const state: BannerState = { + tag: 'New:', + mainText: 'This is a very long main text line that should wrap automatically.', + subText: null, + }; + const lines = new BannerComponent(state).render(width); + const mainRows = lines.slice(0, -1); + let tagCount = 0; + for (const line of mainRows) { + if (line.includes('✦ New:')) tagCount += 1; + } + expect(tagCount).toBe(1); + expect(mainRows.length).toBeGreaterThan(1); + const firstIndex = lines.findIndex((line) => line.includes('✦ New:')); + expect(firstIndex).toBe(0); + }); + + it('continues main text under the tag column and keeps subtext aligned with the tag text', () => { + const width = 80; + const lines = new BannerComponent(banner).render(width); + const firstLine = lines[0]!; + const mainStartIndex = firstLine.indexOf('This is the main banner message'); + const tagPrefixVisibleWidth = visibleWidth(firstLine.slice(0, mainStartIndex)); + const subLine = lines[1]!; + const subStartIndex = subLine.indexOf('This is a short subtext'); + const subIndentVisibleWidth = visibleWidth(subLine.slice(0, subStartIndex)); + // The subtext starts two columns after the left edge ("✦ "), which aligns + // with the tag text itself rather than the main-text column. + expect(subIndentVisibleWidth).toBe(visibleWidth('✦ ')); + expect(tagPrefixVisibleWidth).toBeGreaterThan(visibleWidth('✦ ')); + }); + + it('drops the tag when it does not fit', () => { + const width = 5; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).not.toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('still renders the tag when it fits', () => { + const width = 40; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('does not render subtext when empty', () => { + const lines = new BannerComponent({ tag: 'Tip', mainText: 'Use /help', subText: null }).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).toContain('Use /help'); + expect(lines[1]).toBe(''); + }); + + it('supports explicit newlines in main text', () => { + const lines = new BannerComponent({ tag: null, mainText: 'Line 1\nLine 2', subText: null }).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('Line 1'); + expect(lines[1]).toContain('Line 2'); + expect(lines[2]).toBe(''); + }); + + it('styles tag, main text, and subtext with theme colors', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).toContain(currentTheme.boldFg('primary', "✦ What's new:")); + expect(lines[0]).toContain( + currentTheme.boldFg('textStrong', 'This is the main banner message for testing purposes.'), + ); + expect(lines[1]).toContain(currentTheme.fg('textDim', 'This is a short subtext line.')); + }); + + it('does not stack the dim modifier on top of the textDim color', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[1]).not.toContain(''); + expect(lines[2]).toBe(''); + }); + + it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { + const width = 20; + const lines = new BannerComponent({ + tag: 'New:', + mainText: 'Line 1 with a lot of content', + subText: 'Sub text', + }).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines[0]).toContain('✦ New:'); + const firstLine = lines[0]!; + const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); + const continuationLine = lines.find((line) => line.includes('lot of'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const subLine = lines.find((line) => line.includes('Sub text'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 7cc5e1996..f207d3914 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -4,6 +4,9 @@ import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { BannerProvider } from "#/tui/banner/banner-provider"; +import { BannerComponent } from "#/tui/components/chrome/banner"; +import { WelcomeComponent } from "#/tui/components/chrome/welcome"; import { handleLoginCommand, handleLogoutCommand, @@ -920,6 +923,48 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); + it("renders the banner below the welcome message after it loads", async () => { + const banner = { + tag: "New", + mainText: "Banner main", + subText: null, + }; + const loadSpy = vi + .spyOn(BannerProvider.prototype, "load") + .mockResolvedValue(banner); + const session = makeSession({ id: "ses-target" }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: "ses-target" }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some( + (child) => child instanceof BannerComponent, + ), + ).toBe(true); + }); + + // The banner is rendered directly below the welcome panel so it appears + // above later status messages such as MCP server connection summaries. + const welcomeIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const bannerIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof BannerComponent, + ); + expect(welcomeIndex).toBeGreaterThanOrEqual(0); + expect(bannerIndex).toBe(welcomeIndex + 1); + + loadSpy.mockRestore(); + }); + it("resumes a startup session when Windows workdir uses backslashes", async () => { const session = makeSession({ id: "ses-target" }); const harness = makeHarness(session, { From 596cadd465f72b94c81d59c5f98adc4c2e1f490a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 11 Jun 2026 23:16:02 +0800 Subject: [PATCH 045/476] feat: support always-thinking models via supports_thinking_type (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the /models three-state supports_thinking_type field ('only' / 'no' / 'both', taking precedence over the legacy supports_reasoning boolean) onto the existing always_thinking capability: - oauth: parse the field in both /models parsers; 'only' emits always_thinking alongside thinking, 'no' suppresses thinking even when supports_reasoning is set, absent falls back to the legacy boolean. Default thinking selection is forced on for 'only' (and off for 'no') models during login and provider refresh - TUI: render the thinking control with a fixed On/Off layout — locked models show a greyed-out "Off (Unsupported)" segment, and non-thinking models mirror the style with "On (Unsupported)" - agent-core: clamp thinkingLevel at the getter so a stale thinking-off config can never reach the request builder, status events, or subagent inheritance - acp-adapter: derive alwaysThinking from capabilities, collapse the thinking select to a single locked "on" entry, and ignore off requests for locked models while re-emitting the snapshot --- .../tui/components/dialogs/model-selector.ts | 10 +- .../src/tui/utils/refresh-providers.ts | 5 +- .../components/dialogs/model-selector.test.ts | 49 +++- .../test/tui/utils/refresh-providers.test.ts | 57 +++++ packages/acp-adapter/src/config-options.ts | 27 +- packages/acp-adapter/src/model-catalog.ts | 16 +- packages/acp-adapter/src/session.ts | 21 ++ .../test/_helpers/harness-stubs.ts | 14 +- .../acp-adapter/test/config-options.test.ts | 26 ++ .../acp-adapter/test/model-catalog.test.ts | 37 +++ .../test/set-session-config-option.test.ts | 37 +++ packages/agent-core/src/agent/config/index.ts | 11 + .../src/session/provider-manager.ts | 5 + .../test/agent/config-state.test.ts | 60 +++++ packages/oauth/src/managed-kimi-code.ts | 54 +++- packages/oauth/src/open-platform.ts | 19 +- packages/oauth/test/managed-kimi-code.test.ts | 230 ++++++++++++++++++ packages/oauth/test/open-platform.test.ts | 71 ++++++ 18 files changed, 735 insertions(+), 14 deletions(-) create mode 100644 packages/acp-adapter/test/model-catalog.test.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index b6a9ec6ea..f223ba50b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -259,13 +259,19 @@ export class ModelSelectorComponent extends Container implements Focusable { active ? currentTheme.boldFg('primary', `[ ${label} ]`) : currentTheme.fg('text', ` ${label} `); + // The whole segment is muted, suffix included, so the disabled side reads + // as a single greyed-out control rather than a selectable option. + const unavailable = (label: string): string => + currentTheme.fg('textMuted', ` ${label} (Unsupported) `); + // On stays left and Off right in all three states so the control never + // shifts while the cursor moves across models. const availability = thinkingAvailability(choice.model); if (availability === 'always-on') { - return ` ${segment('Always on', true)}`; + return ` ${segment('On', true)} ${unavailable('Off')}`; } if (availability === 'unsupported') { - return ` ${segment('Off', true)} ${currentTheme.fg('textMuted', 'unsupported')}`; + return ` ${unavailable('On')} ${segment('Off', true)}`; } const draft = this.draftFor(choice); return ` ${segment('On', draft)} ${segment('Off', !draft)}`; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index ab186f562..aa8cd7577 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -182,7 +182,10 @@ function restoreDefaultSelection( ): void { if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; config.defaultModel = defaultModel; - config.defaultThinking = defaultThinking; + // A refresh may have just learned that the default model cannot disable + // thinking — never restore a stale thinking-off selection onto it. + const capabilities = config.models[defaultModel]?.capabilities ?? []; + config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; } // `apply*` may leave `defaultModel` pointing at an alias that no longer exists diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index dd4780e64..eec53eca4 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -3,6 +3,7 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI = /\[[0-9;]*m/g; @@ -94,16 +95,60 @@ describe('ModelSelectorComponent', () => { onCancel: vi.fn(), }); - expect(text(picker)).toContain('[ Always on ]'); + // Always-on: On selected, Off greyed out with an explanation. + const alwaysOut = text(picker); + expect(alwaysOut).toContain('[ On ]'); + expect(alwaysOut).toContain('Off (Unsupported)'); + expect(alwaysOut).not.toContain('Always on'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + + // Unsupported: Off selected, On greyed out — same style, mirrored. + picker.handleInput(DOWN); + const plainOut = text(picker); + expect(plainOut).toContain('On (Unsupported)'); + expect(plainOut).toContain('[ Off ]'); + expect(plainOut).not.toContain('] unsupported'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + }); + + it('ignores Left/Right on always-on and unsupported models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: model('Kimi Thinking', ['always_thinking']), + plain: model('Kimi Plain', ['tool_use']), + }, + currentValue: 'always', + currentThinking: true, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(RIGHT); picker.handleInput('\r'); expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); picker.handleInput(DOWN); - expect(text(picker)).toContain('[ Off ] unsupported'); + picker.handleInput(LEFT); picker.handleInput('\r'); expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); }); + it('renders the unavailable thinking segment muted', () => { + const picker = new ModelSelectorComponent({ + models: { always: model('Kimi Thinking', ['always_thinking']) }, + currentValue: 'always', + currentThinking: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); + }); + it('keeps the thinking draft when moving across models', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index 41caefa22..d2b0d778b 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -342,4 +342,61 @@ describe('refreshAllProviderModels', () => { expect(host.current().defaultModel).toBe(userAlias); expect(host.current().defaultThinking).toBe(false); }); + + it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { + const host = makeRefreshHost({ + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + models: { + 'kimi-code/kimi-deep-coder': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-deep-coder', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-deep-coder', + defaultThinking: false, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-deep-coder', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'only', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(host.current().models?.['kimi-code/kimi-deep-coder']?.capabilities).toEqual([ + 'thinking', + 'always_thinking', + 'tool_use', + ]); + expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); + expect(host.current().defaultThinking).toBe(true); + }); }); diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts index eab73eb0a..a73077751 100644 --- a/packages/acp-adapter/src/config-options.ts +++ b/packages/acp-adapter/src/config-options.ts @@ -96,8 +96,28 @@ export function buildModelOption( * currently-selected model has `thinkingSupported === false`, the * snapshot omits it entirely (dynamic visibility), so the client never * shows a toggle that wouldn't do anything. + * + * `alwaysThinking` models (declared `always_thinking` capability — the + * runtime cannot disable thinking) collapse the select to a single + * locked `on` entry: the state stays visible to the client, but there + * is no off option to pick. ACP has no "disabled entry" concept, so + * omitting `off` is the wire-level equivalent of the TUI's greyed-out + * `Off (Unsupported)` segment. */ -export function buildThinkingOption(enabled: boolean): SessionConfigOption { +export function buildThinkingOption( + enabled: boolean, + alwaysThinking = false, +): SessionConfigOption { + if (alwaysThinking) { + return { + type: 'select', + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + currentValue: 'on', + options: [{ value: 'on', name: 'Thinking On' }], + }; + } return { type: 'select', id: 'thinking', @@ -171,9 +191,12 @@ export async function buildSessionConfigOptions( const models = await listModelsFromHarness(harness); const currentModelEntry = models.find((m) => m.id === currentBaseModelId); const showThinking = currentModelEntry?.thinkingSupported === true; + const alwaysThinking = currentModelEntry?.alwaysThinking === true; const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)]; if (showThinking) { - out.push(buildThinkingOption(currentThinkingEnabled)); + // Always-thinking models render locked-on regardless of the session's + // recorded toggle state — agent-core clamps the runtime the same way. + out.push(buildThinkingOption(alwaysThinking || currentThinkingEnabled, alwaysThinking)); } out.push(buildModeOption(currentModeId)); return out; diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index 4992b491b..e126362b4 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -35,6 +35,8 @@ export interface AcpModelEntry { readonly name: string; readonly description?: string | undefined; readonly thinkingSupported: boolean; + /** Declared 'always_thinking' capability — thinking cannot be turned off. */ + readonly alwaysThinking?: boolean; } /** @@ -47,13 +49,24 @@ const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']); export function deriveThinkingSupported(alias: ModelAlias): boolean { const declared = alias.capabilities ?? []; - if (declared.includes('thinking')) return true; + if (declared.includes('thinking') || declared.includes('always_thinking')) return true; const lower = alias.model.toLowerCase(); if (lower.includes('thinking') || lower.includes('reason')) return true; if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true; return false; } +/** + * Whether the alias declares the 'always_thinking' capability — the model + * cannot run with thinking disabled, so the ACP toggle must lock to on. + * Deliberately capability-only: the name heuristics above keep feeding + * `thinkingSupported`, but only an explicit (server-derived) declaration + * may remove the off option from the client. + */ +export function deriveAlwaysThinking(alias: ModelAlias): boolean { + return (alias.capabilities ?? []).includes('always_thinking'); +} + /** * Project `harness.getConfig().models` into a flat catalog. Returns an * empty array when the harness has no models configured, when @@ -80,6 +93,7 @@ export async function listModelsFromHarness( id, name: alias.displayName ?? alias.model ?? id, thinkingSupported: deriveThinkingSupported(alias), + alwaysThinking: deriveAlwaysThinking(alias), }); } return out; diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 6b38edc8e..53c50beb5 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -37,6 +37,7 @@ import { type AcpBuiltinSlashCommandName, } from './builtin-commands'; import { buildSessionConfigOptions } from './config-options'; +import { listModelsFromHarness } from './model-catalog'; import { acpBlocksToPromptParts } from './convert'; import { acpToolCallId, @@ -365,6 +366,15 @@ export class AcpSession { * carries a fresh snapshot. */ async setThinking(enabled: boolean): Promise { + if (!enabled && (await this.currentModelAlwaysThinking())) { + // The current model cannot disable thinking (declared + // 'always_thinking'); silently ignore the off request — agent-core + // clamps the runtime the same way — but still refresh the snapshot + // so a stale client toggle snaps back to on. + this.currentThinkingEnabledInternal = true; + await this.emitConfigOptionUpdate(); + return; + } if (typeof this.session.setThinking === 'function') { await this.session.setThinking(enabled ? THINKING_ON_LEVEL : THINKING_OFF_LEVEL); } @@ -372,6 +382,17 @@ export class AcpSession { await this.emitConfigOptionUpdate(); } + /** + * Whether the currently-selected model declares 'always_thinking'. + * Harness-less adapter unit tests resolve to false — the agent-core + * runtime clamp still protects the actual request in that case. + */ + private async currentModelAlwaysThinking(): Promise { + if (!this.harness) return false; + const models = await listModelsFromHarness(this.harness); + return models.find((m) => m.id === this.currentModelIdInternal)?.alwaysThinking === true; + } + /** * Forward an ACP `session/set_mode` request to the underlying SDK * session. diff --git a/packages/acp-adapter/test/_helpers/harness-stubs.ts b/packages/acp-adapter/test/_helpers/harness-stubs.ts index 54b11375e..0e34f0446 100644 --- a/packages/acp-adapter/test/_helpers/harness-stubs.ts +++ b/packages/acp-adapter/test/_helpers/harness-stubs.ts @@ -29,10 +29,20 @@ export const UNAUTHED_STATUS = { * mirrors what a real config file would carry. */ export function makeModelsMap( - entries: ReadonlyArray<{ id: string; name?: string; thinkingSupported?: boolean }>, + entries: ReadonlyArray<{ + id: string; + name?: string; + thinkingSupported?: boolean; + alwaysThinking?: boolean; + }>, ): Record { const out: Record = {}; for (const entry of entries) { + const capabilities = entry.alwaysThinking === true + ? ['thinking', 'always_thinking'] + : entry.thinkingSupported === true + ? ['thinking'] + : undefined; out[entry.id] = { // The fields below are the minimum shape the adapter reads off // each alias — `provider`/`max_context_size` are required by the @@ -40,7 +50,7 @@ export function makeModelsMap( // here and the partial-record cast keeps the test stub honest. model: entry.id, ...(entry.name !== undefined ? { displayName: entry.name } : {}), - ...(entry.thinkingSupported === true ? { capabilities: ['thinking'] } : {}), + ...(capabilities !== undefined ? { capabilities } : {}), } as ModelAlias; } return out; diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index 89e73717b..6aa1d6318 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -94,6 +94,14 @@ describe('buildThinkingOption', () => { if (off.type !== 'select') throw new Error('expected SessionConfigSelect'); expect(off.currentValue).toBe('off'); }); + + it('collapses to a single locked "on" entry for always-thinking models', () => { + const locked = buildThinkingOption(true, true); + if (locked.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(locked.currentValue).toBe('on'); + expect(locked.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']); + expect(locked.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Thinking On']); + }); }); describe('buildModeOption', () => { @@ -171,6 +179,24 @@ describe('buildSessionConfigOptions', () => { expect(toggle.currentValue).toBe('on'); }); + it('locks the thinking toggle to on for always-thinking models even when the session state says off', async () => { + const { harness } = makeHarnessWithModels([ + { + id: 'kimi-deep', + model: 'kimi-deep-coder', + displayName: 'Kimi Deep', + capabilities: ['thinking', 'always_thinking'], + }, + ]); + + const result = await buildSessionConfigOptions(harness, 'kimi-deep', false, 'default'); + + const toggle = result.find((o) => o.id === 'thinking'); + if (!toggle || toggle.type !== 'select') throw new Error('expected thinking select toggle'); + expect(toggle.currentValue).toBe('on'); + expect(toggle.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']); + }); + it('omits the thinking toggle when the current base model id is not in the catalog (defensive)', async () => { const { harness } = makeHarnessWithModels([ { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts new file mode 100644 index 000000000..59a340300 --- /dev/null +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +import { deriveAlwaysThinking, deriveThinkingSupported } from '../src/model-catalog'; + +function alias(model: string, capabilities?: readonly string[]): ModelAlias { + return { + model, + ...(capabilities !== undefined ? { capabilities } : {}), + } as unknown as ModelAlias; +} + +describe('deriveThinkingSupported', () => { + it('treats a declared always_thinking capability as thinking-supported', () => { + expect(deriveThinkingSupported(alias('custom-model', ['always_thinking']))).toBe(true); + }); + + it('keeps the existing thinking-capability and name-heuristic triggers', () => { + expect(deriveThinkingSupported(alias('custom-model', ['thinking']))).toBe(true); + expect(deriveThinkingSupported(alias('some-thinking-model'))).toBe(true); + expect(deriveThinkingSupported(alias('plain-model'))).toBe(false); + }); +}); + +describe('deriveAlwaysThinking', () => { + it('reads the declared always_thinking capability', () => { + expect(deriveAlwaysThinking(alias('custom-model', ['thinking', 'always_thinking']))).toBe(true); + expect(deriveAlwaysThinking(alias('custom-model', ['thinking']))).toBe(false); + }); + + it('does not infer always-thinking from the model name', () => { + // Name heuristics keep working for thinkingSupported, but only the + // server-declared capability may lock the toggle to on. + expect(deriveAlwaysThinking(alias('some-thinking-model'))).toBe(false); + }); +}); diff --git a/packages/acp-adapter/test/set-session-config-option.test.ts b/packages/acp-adapter/test/set-session-config-option.test.ts index a90eaf8d6..a037808ed 100644 --- a/packages/acp-adapter/test/set-session-config-option.test.ts +++ b/packages/acp-adapter/test/set-session-config-option.test.ts @@ -226,6 +226,43 @@ describe('AcpServer session/set_config_option', () => { expect(respToggle.currentValue).toBe('off'); }); + it('configId="thinking" + "off" on an always-thinking model → no SDK call, toggle stays locked on', async () => { + const handle = makeFakeSession('sess-thinking-locked'); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-deep', + models: makeModelsMap([ + { id: 'kimi-deep', name: 'Kimi Deep', thinkingSupported: true, alwaysThinking: true }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'off', + }); + + // The off request is silently ignored — the runtime cannot disable + // thinking on this model, so no SDK call is forwarded. + expect(handle.setThinkingCalls).toEqual([]); + const respToggle = response.configOptions.find((o) => o.id === 'thinking'); + if (!respToggle || respToggle.type !== 'select') throw new Error('expected select toggle'); + expect(respToggle.currentValue).toBe('on'); + expect(respToggle.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']); + + // A snapshot refresh is still emitted so a stale client toggle snaps back. + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + }); + const MODE_CASES: ReadonlyArray<{ modeId: 'default' | 'plan' | 'auto' | 'yolo'; expectedPlan: boolean; diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index df9d61b13..8e89aaf3a 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -120,9 +120,20 @@ export class ConfigState { } get thinkingLevel(): ThinkingEffort { + // Always-thinking models cannot run with thinking disabled. Clamping in + // the getter (rather than in update()) keeps the request builder, status + // events, and subagent inheritance consistent, and re-applies after a + // later model switch onto an always-thinking alias. + if (this._thinkingLevel === 'off' && this.alwaysThinkingModel) { + return resolveThinkingEffort('on', this.agent.kimiConfig?.thinking); + } return this._thinkingLevel; } + private get alwaysThinkingModel(): boolean { + return this.tryResolvedProviderConfig()?.alwaysThinking === true; + } + get profileName(): string | undefined { return this._profileName; } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 38d3e1cd1..f37001142 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -17,6 +17,8 @@ export interface ResolvedRuntimeProvider { readonly providerName: string; readonly provider: KosongProviderConfig; readonly modelCapabilities: ModelCapability; + /** Declared 'always_thinking' capability — the model cannot disable thinking. */ + readonly alwaysThinking?: boolean; readonly maxOutputSize?: number; } @@ -116,6 +118,9 @@ export class ProviderManager implements ModelProvider { providerName, provider, modelCapabilities: resolveModelCapabilities(alias, provider), + alwaysThinking: (alias.capabilities ?? []).some( + (c) => c.trim().toLowerCase() === 'always_thinking', + ), maxOutputSize: alias.maxOutputSize, }; } diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 75de6e81a..ddccdef52 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -159,6 +159,66 @@ describe('ConfigState model capabilities', () => { }); }); +describe('ConfigState thinking clamp for always-thinking models', () => { + function alwaysThinkingAgent() { + return testAgent({ + providerManager: new ProviderManager({ + config: { + providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, + models: { + 'kimi-code/deep': { + provider: 'kimi', + model: 'kimi-deep-coder', + maxContextSize: 128_000, + capabilities: ['thinking', 'always_thinking', 'tool_use'], + }, + 'kimi-code/toggle': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + }, + }, + }), + }); + } + + it('clamps thinkingLevel off to the configured effort', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' }); + + expect(ctx.agent.config.thinkingLevel).toBe('high'); + }); + + it('builds the provider with thinking enabled even after thinking was set off', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' }); + + const provider = ctx.agent.config.provider; + const gen = Reflect.get(provider as object, '_generationKwargs') as { + extra_body?: { thinking?: { type?: unknown } }; + }; + expect(gen.extra_body?.thinking?.type).toBe('enabled'); + }); + + it('keeps thinking off working for toggleable models', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' }); + + expect(ctx.agent.config.thinkingLevel).toBe('off'); + }); + + it('re-clamps when switching to an always-on model after thinking was off', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' }); + expect(ctx.agent.config.thinkingLevel).toBe('off'); + + ctx.agent.config.update({ modelAlias: 'kimi-code/deep' }); + expect(ctx.agent.config.thinkingLevel).toBe('high'); + }); +}); + describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { function kimiAgent() { return testAgent({ diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index ee612ea63..b9124caaa 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -11,6 +11,15 @@ export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code'; const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-'; +/** + * Server-declared thinking toggle support from `/models`: + * - 'only' — thinking cannot be turned off (always-thinking) + * - 'no' — thinking is not supported at all + * - 'both' — thinking can be toggled on and off + * Absent on older servers — callers fall back to `supportsReasoning`. + */ +export type SupportsThinkingType = 'only' | 'no' | 'both'; + export interface ManagedKimiCodeModelInfo { readonly id: string; readonly contextLength: number; @@ -18,6 +27,7 @@ export interface ManagedKimiCodeModelInfo { readonly supportsImageIn: boolean; readonly supportsVideoIn: boolean; readonly supportsToolUse?: boolean; + readonly supportsThinkingType?: SupportsThinkingType; readonly displayName?: string | undefined; } @@ -171,7 +181,22 @@ interface SelectedDefaultModel { function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); - if (model.supportsReasoning) caps.add('thinking'); + // supports_thinking_type is the full three-state declaration and wins over + // the legacy supports_reasoning boolean; absent (older servers) falls back. + switch (model.supportsThinkingType) { + case 'only': + caps.add('thinking'); + caps.add('always_thinking'); + break; + case 'both': + caps.add('thinking'); + break; + case 'no': + break; + case undefined: + if (model.supportsReasoning) caps.add('thinking'); + break; + } if (model.supportsImageIn) caps.add('image_in'); if (model.supportsVideoIn) caps.add('video_in'); if (model.supportsToolUse ?? true) caps.add('tool_use'); @@ -357,10 +382,17 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { supportsImageIn: Boolean(item['supports_image_in']), supportsVideoIn: Boolean(item['supports_video_in']), supportsToolUse, + supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), displayName: normalizedDisplayName, }; } +// Unknown or missing values resolve to undefined so callers fall back to the +// legacy supports_reasoning boolean instead of guessing. +export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { + return value === 'only' || value === 'no' || value === 'both' ? value : undefined; +} + export async function fetchManagedKimiCodeModels( options: FetchManagedKimiCodeModelsOptions, ): Promise { @@ -496,6 +528,19 @@ export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape) } } +// The server's three-state declaration overrides any stale defaultThinking +// being preserved from an earlier config: an always-thinking model ('only') +// must never end up with thinking off, and a non-thinking model ('no') must +// never end up with thinking on. +function forcedThinking( + model: ManagedKimiCodeModelInfo | undefined, + fallback: boolean, +): boolean { + if (model?.supportsThinkingType === 'only') return true; + if (model?.supportsThinkingType === 'no') return false; + return fallback; +} + function selectDefaultModel( config: ManagedKimiConfigShape, models: readonly ManagedKimiCodeModelInfo[], @@ -521,13 +566,16 @@ function selectDefaultModel( const preservedModel = managedModels.get(currentDefault); return { modelKey: currentDefault, - thinking: config.defaultThinking ?? preservedModel?.supportsReasoning ?? false, + thinking: forcedThinking( + preservedModel, + config.defaultThinking ?? preservedModel?.supportsReasoning ?? false, + ), }; } return { modelKey: managedModelKey(firstModel.id), - thinking: config.defaultThinking ?? firstModel.supportsReasoning, + thinking: forcedThinking(firstModel, config.defaultThinking ?? firstModel.supportsReasoning), }; } diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index d32f931d9..0b6f74433 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,5 +1,6 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; +import { parseSupportsThinkingType } from './managed-kimi-code'; import type { ManagedKimiCodeModelInfo, ManagedKimiConfigShape, @@ -61,13 +62,29 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { supportsImageIn: Boolean(item['supports_image_in']), supportsVideoIn: Boolean(item['supports_video_in']), supportsToolUse, + supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), displayName: normalizedDisplayName, }; } export function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); - if (model.supportsReasoning) caps.add('thinking'); + // supports_thinking_type is the full three-state declaration and wins over + // the legacy supports_reasoning boolean; absent (older servers) falls back. + switch (model.supportsThinkingType) { + case 'only': + caps.add('thinking'); + caps.add('always_thinking'); + break; + case 'both': + caps.add('thinking'); + break; + case 'no': + break; + case undefined: + if (model.supportsReasoning) caps.add('thinking'); + break; + } if (model.supportsImageIn) caps.add('image_in'); if (model.supportsVideoIn) caps.add('video_in'); if (model.supportsToolUse ?? true) caps.add('tool_use'); diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index ea1d4f1de..6857925c6 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -838,3 +838,233 @@ describe('provisionManagedKimiCodeConfig', () => { }); }); }); + +describe('supports_thinking_type', () => { + function makeThinkingTypeModelsResponse(): Response { + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + supports_image_in: true, + supports_video_in: true, + supports_thinking_type: 'only', + display_name: 'Kimi For Coding', + }, + { + // 'no' is the authoritative declaration and overrides the legacy + // supports_reasoning boolean. + id: 'kimi-plain', + context_length: 128000, + supports_reasoning: true, + supports_thinking_type: 'no', + }, + { + id: 'kimi-toggle', + context_length: 128000, + supports_reasoning: true, + supports_thinking_type: 'both', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + it('parses supports_thinking_type from the models endpoint', async () => { + const models = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + }); + + expect(models[0]?.supportsThinkingType).toBe('only'); + expect(models[1]?.supportsThinkingType).toBe('no'); + expect(models[2]?.supportsThinkingType).toBe('both'); + }); + + it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => { + const absent = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, + }); + expect(absent[0]?.supportsThinkingType).toBeUndefined(); + + const invalid = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'maybe', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch, + }); + expect(invalid[0]?.supportsThinkingType).toBeUndefined(); + }); + + it('maps the three states onto capabilities, overriding supports_reasoning', async () => { + const config: ManagedKimiConfigShape = { providers: {} }; + + await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + // 'only' → thinking locked on. + expect(config.models?.['kimi-code/kimi-for-coding']?.capabilities).toEqual([ + 'thinking', + 'always_thinking', + 'image_in', + 'video_in', + 'tool_use', + ]); + // 'no' → no thinking capability despite supports_reasoning=true. + expect(config.models?.['kimi-code/kimi-plain']?.capabilities).toEqual(['tool_use']); + // 'both' → plain toggleable thinking. + expect(config.models?.['kimi-code/kimi-toggle']?.capabilities).toEqual([ + 'thinking', + 'tool_use', + ]); + }); + + it('forces default thinking on when the selected default model is thinking-only', async () => { + const config: ManagedKimiConfigShape = { providers: {}, defaultThinking: false }; + + const result = await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); + expect(result.defaultThinking).toBe(true); + expect(config.defaultThinking).toBe(true); + }); + + it('forces default thinking on when preserving a thinking-only managed default', async () => { + const config: ManagedKimiConfigShape = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + defaultThinking: false, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking'], + }, + }, + }; + + const result = await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + preserveDefaultModel: true, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); + expect(result.defaultThinking).toBe(true); + expect(config.defaultThinking).toBe(true); + }); + + it('forces default thinking off when preserving a no-thinking managed default', async () => { + const config: ManagedKimiConfigShape = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + }, + }, + defaultModel: 'kimi-code/kimi-plain', + defaultThinking: true, + models: { + 'kimi-code/kimi-plain': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-plain', + maxContextSize: 128000, + capabilities: ['thinking'], + }, + }, + }; + + const result = await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + preserveDefaultModel: true, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + expect(result.defaultModel).toBe('kimi-code/kimi-plain'); + expect(result.defaultThinking).toBe(false); + expect(config.defaultThinking).toBe(false); + }); + + it('keeps a preserved non-managed default thinking selection untouched', async () => { + const config: ManagedKimiConfigShape = { + providers: { + custom: { + type: 'kimi', + apiKey: 'sk-existing', + }, + }, + defaultModel: 'custom-default', + defaultThinking: false, + models: { + 'custom-default': { + provider: 'custom', + model: 'custom-model', + maxContextSize: 1000, + }, + }, + }; + + const result = await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, + preserveDefaultModel: true, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + expect(result.defaultModel).toBe('custom-default'); + expect(result.defaultThinking).toBe(false); + expect(config.defaultThinking).toBe(false); + }); +}); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index b127fd6b7..eb53829f1 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -155,7 +155,78 @@ describe('filterModelsByPrefix', () => { }); }); +describe('fetchOpenPlatformModels supports_thinking_type', () => { + it('parses supports_thinking_type from the models endpoint', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-k2-deep', + context_length: 256000, + supports_reasoning: true, + supports_thinking_type: 'only', + }, + { + id: 'kimi-k2-lite', + context_length: 128000, + supports_reasoning: false, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + const platform = getOpenPlatformById('moonshot-cn')!; + + const models = await fetchOpenPlatformModels(platform, 'sk-test', fetchMock as unknown as typeof fetch); + + expect(models[0]?.supportsThinkingType).toBe('only'); + expect(models[1]?.supportsThinkingType).toBeUndefined(); + }); +}); + describe('capabilitiesForModel', () => { + it("locks thinking on for 'only' models", () => { + const model = { + id: 'deep', + contextLength: 1000, + supportsReasoning: true, + supportsImageIn: false, + supportsVideoIn: false, + supportsToolUse: false, + supportsThinkingType: 'only' as const, + }; + expect(capabilitiesForModel(model)).toEqual(['thinking', 'always_thinking']); + }); + + it("lets 'no' override the legacy supports_reasoning boolean", () => { + const model = { + id: 'plain', + contextLength: 1000, + supportsReasoning: true, + supportsImageIn: false, + supportsVideoIn: false, + supportsToolUse: false, + supportsThinkingType: 'no' as const, + }; + expect(capabilitiesForModel(model)).toBeUndefined(); + }); + + it("emits a plain toggleable thinking capability for 'both'", () => { + const model = { + id: 'toggle', + contextLength: 1000, + supportsReasoning: false, + supportsImageIn: false, + supportsVideoIn: false, + supportsToolUse: false, + supportsThinkingType: 'both' as const, + }; + expect(capabilitiesForModel(model)).toEqual(['thinking']); + }); + it('returns undefined for a model with no capabilities', () => { const model = { id: 'plain', From 0a3e87f05acc9e49316cb1c8d8e74e34d16ab2de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:54:44 +0800 Subject: [PATCH 046/476] ci: release packages (#629) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/agent-swarm-exclusive-call.md | 6 ---- .../autocomplete-two-line-descriptions.md | 5 --- .changeset/cancel-active-turns-on-close.md | 6 ---- .changeset/fix-bash-premature-close.md | 6 ---- .changeset/fix-provider-media-placeholders.md | 6 ---- .changeset/fix-windows-acp-paths.md | 6 ---- .changeset/openai-responses-instructions.md | 6 ---- .changeset/propagate-exec-env-overrides.md | 7 ---- .../refine-builtin-skills-home-resolution.md | 6 ---- .changeset/runtime-support-updates.md | 7 ---- .../slash-command-alias-autocomplete.md | 5 --- .changeset/steady-agent-scopes.md | 6 ---- .changeset/stop-background-tasks-on-exit.md | 6 ---- .changeset/swarm-yolo-start-option.md | 5 --- .changeset/tips-banner.md | 5 --- apps/kimi-code/CHANGELOG.md | 34 +++++++++++++++++++ apps/kimi-code/package.json | 2 +- packages/acp-adapter/CHANGELOG.md | 13 +++++++ packages/acp-adapter/package.json | 2 +- packages/agent-core/CHANGELOG.md | 20 +++++++++++ packages/agent-core/package.json | 2 +- packages/kaos/CHANGELOG.md | 6 ++++ packages/kaos/package.json | 2 +- packages/kosong/CHANGELOG.md | 10 ++++++ packages/kosong/package.json | 2 +- packages/node-sdk/CHANGELOG.md | 6 ++++ packages/node-sdk/package.json | 2 +- 27 files changed, 95 insertions(+), 94 deletions(-) delete mode 100644 .changeset/agent-swarm-exclusive-call.md delete mode 100644 .changeset/autocomplete-two-line-descriptions.md delete mode 100644 .changeset/cancel-active-turns-on-close.md delete mode 100644 .changeset/fix-bash-premature-close.md delete mode 100644 .changeset/fix-provider-media-placeholders.md delete mode 100644 .changeset/fix-windows-acp-paths.md delete mode 100644 .changeset/openai-responses-instructions.md delete mode 100644 .changeset/propagate-exec-env-overrides.md delete mode 100644 .changeset/refine-builtin-skills-home-resolution.md delete mode 100644 .changeset/runtime-support-updates.md delete mode 100644 .changeset/slash-command-alias-autocomplete.md delete mode 100644 .changeset/steady-agent-scopes.md delete mode 100644 .changeset/stop-background-tasks-on-exit.md delete mode 100644 .changeset/swarm-yolo-start-option.md delete mode 100644 .changeset/tips-banner.md diff --git a/.changeset/agent-swarm-exclusive-call.md b/.changeset/agent-swarm-exclusive-call.md deleted file mode 100644 index a2da15cae..000000000 --- a/.changeset/agent-swarm-exclusive-call.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Require AgentSwarm tool calls to run alone in a model response. diff --git a/.changeset/autocomplete-two-line-descriptions.md b/.changeset/autocomplete-two-line-descriptions.md deleted file mode 100644 index 4a8d25fde..000000000 --- a/.changeset/autocomplete-two-line-descriptions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. diff --git a/.changeset/cancel-active-turns-on-close.md b/.changeset/cancel-active-turns-on-close.md deleted file mode 100644 index b6095915c..000000000 --- a/.changeset/cancel-active-turns-on-close.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. diff --git a/.changeset/fix-bash-premature-close.md b/.changeset/fix-bash-premature-close.md deleted file mode 100644 index 65cd6ca6b..000000000 --- a/.changeset/fix-bash-premature-close.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Fix premature stream close errors when shell processes time out or are killed. diff --git a/.changeset/fix-provider-media-placeholders.md b/.changeset/fix-provider-media-placeholders.md deleted file mode 100644 index e506b28ac..000000000 --- a/.changeset/fix-provider-media-placeholders.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. diff --git a/.changeset/fix-windows-acp-paths.md b/.changeset/fix-windows-acp-paths.md deleted file mode 100644 index 16d9f9aa8..000000000 --- a/.changeset/fix-windows-acp-paths.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/acp-adapter": patch -"@moonshot-ai/kimi-code": patch ---- - -Fix ACP file reads and edits for Windows workspaces opened through IDE clients. diff --git a/.changeset/openai-responses-instructions.md b/.changeset/openai-responses-instructions.md deleted file mode 100644 index 32bf57264..000000000 --- a/.changeset/openai-responses-instructions.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Send OpenAI Responses system prompts as request instructions. diff --git a/.changeset/propagate-exec-env-overrides.md b/.changeset/propagate-exec-env-overrides.md deleted file mode 100644 index 3839ed69d..000000000 --- a/.changeset/propagate-exec-env-overrides.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/kaos": patch -"@moonshot-ai/acp-adapter": patch -"@moonshot-ai/kimi-code": patch ---- - -Propagate configured execution environment overrides across spawned processes. diff --git a/.changeset/refine-builtin-skills-home-resolution.md b/.changeset/refine-builtin-skills-home-resolution.md deleted file mode 100644 index 08da24af6..000000000 --- a/.changeset/refine-builtin-skills-home-resolution.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Polish builtin skills. diff --git a/.changeset/runtime-support-updates.md b/.changeset/runtime-support-updates.md deleted file mode 100644 index 050bed3d8..000000000 --- a/.changeset/runtime-support-updates.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kosong": patch -"@moonshot-ai/kimi-code": patch ---- - -Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. diff --git a/.changeset/slash-command-alias-autocomplete.md b/.changeset/slash-command-alias-autocomplete.md deleted file mode 100644 index a009d72ef..000000000 --- a/.changeset/slash-command-alias-autocomplete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. diff --git a/.changeset/steady-agent-scopes.md b/.changeset/steady-agent-scopes.md deleted file mode 100644 index fdf9bd239..000000000 --- a/.changeset/steady-agent-scopes.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/kimi-code-sdk": patch ---- - -Prevent overlapping interactive agent requests from using the wrong active agent. diff --git a/.changeset/stop-background-tasks-on-exit.md b/.changeset/stop-background-tasks-on-exit.md deleted file mode 100644 index b88111c97..000000000 --- a/.changeset/stop-background-tasks-on-exit.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Stop background tasks by default when sessions close. diff --git a/.changeset/swarm-yolo-start-option.md b/.changeset/swarm-yolo-start-option.md deleted file mode 100644 index ed1f2766e..000000000 --- a/.changeset/swarm-yolo-start-option.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Add a YOLO choice when starting swarm tasks from Manual mode. diff --git a/.changeset/tips-banner.md b/.changeset/tips-banner.md deleted file mode 100644 index 2e47cc18a..000000000 --- a/.changeset/tips-banner.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Display a tips banner below the welcome panel on startup. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index d09fac26c..29e6a229d 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,39 @@ # @moonshot-ai/kimi-code +## 0.14.1 + +### Patch Changes + +- [#643](https://github.com/MoonshotAI/kimi-code/pull/643) [`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. + +- [#661](https://github.com/MoonshotAI/kimi-code/pull/661) [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. + +- [#604](https://github.com/MoonshotAI/kimi-code/pull/604) [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. + +- [#632](https://github.com/MoonshotAI/kimi-code/pull/632) [`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. + +- [#628](https://github.com/MoonshotAI/kimi-code/pull/628) [`0ee9106`](https://github.com/MoonshotAI/kimi-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. + +- [#658](https://github.com/MoonshotAI/kimi-code/pull/658) [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. + +- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. + +- [#644](https://github.com/MoonshotAI/kimi-code/pull/644) [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. + +- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. + +- [#648](https://github.com/MoonshotAI/kimi-code/pull/648) [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. + +- [#641](https://github.com/MoonshotAI/kimi-code/pull/641) [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. + +- [#645](https://github.com/MoonshotAI/kimi-code/pull/645) [`1b58aa8`](https://github.com/MoonshotAI/kimi-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting swarm tasks from Manual mode. + +- [#655](https://github.com/MoonshotAI/kimi-code/pull/655) [`1e2e679`](https://github.com/MoonshotAI/kimi-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup. + ## 0.14.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 5805045f0..ff223543a 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.14.0", + "version": "0.14.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/packages/acp-adapter/CHANGELOG.md b/packages/acp-adapter/CHANGELOG.md index 512cbaedc..e6e2f60a3 100644 --- a/packages/acp-adapter/CHANGELOG.md +++ b/packages/acp-adapter/CHANGELOG.md @@ -1,5 +1,18 @@ # @moonshot-ai/acp-adapter +## 0.2.5 + +### Patch Changes + +- [#628](https://github.com/MoonshotAI/kimi-code/pull/628) [`0ee9106`](https://github.com/MoonshotAI/kimi-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. + +- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. + +- Updated dependencies [[`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a), [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c), [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94), [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f), [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6), [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c), [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5), [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a)]: + - @moonshot-ai/agent-core@0.12.2 + - @moonshot-ai/kaos@0.1.5 + - @moonshot-ai/kimi-code-sdk@0.9.2 + ## 0.2.4 ### Patch Changes diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index 46cdcc022..a4d1eba63 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/acp-adapter", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "Agent Client Protocol adapter for kimi-code", "license": "MIT", diff --git a/packages/agent-core/CHANGELOG.md b/packages/agent-core/CHANGELOG.md index 872a15237..062eee985 100644 --- a/packages/agent-core/CHANGELOG.md +++ b/packages/agent-core/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/agent-core +## 0.12.2 + +### Patch Changes + +- [#643](https://github.com/MoonshotAI/kimi-code/pull/643) [`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response. + +- [#661](https://github.com/MoonshotAI/kimi-code/pull/661) [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. + +- [#604](https://github.com/MoonshotAI/kimi-code/pull/604) [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. + +- [#644](https://github.com/MoonshotAI/kimi-code/pull/644) [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. + +- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. + +- [#641](https://github.com/MoonshotAI/kimi-code/pull/641) [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. + +- Updated dependencies [[`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef), [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927), [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f), [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c)]: + - @moonshot-ai/kosong@0.4.4 + - @moonshot-ai/kaos@0.1.5 + ## 0.12.1 ### Patch Changes diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 427150c40..c88a79bc4 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/agent-core", - "version": "0.12.1", + "version": "0.12.2", "private": true, "description": "The unified agent engine for Kimi", "license": "MIT", diff --git a/packages/kaos/CHANGELOG.md b/packages/kaos/CHANGELOG.md index 8f36dba95..2a645004d 100644 --- a/packages/kaos/CHANGELOG.md +++ b/packages/kaos/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kaos +## 0.1.5 + +### Patch Changes + +- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. + ## 0.1.4 ### Patch Changes diff --git a/packages/kaos/package.json b/packages/kaos/package.json index fbe6a03a5..4addee98b 100644 --- a/packages/kaos/package.json +++ b/packages/kaos/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kaos", - "version": "0.1.4", + "version": "0.1.5", "private": true, "description": "Execution environment abstraction for AI agent applications", "license": "MIT", diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index d74265c38..eb6be3bb3 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -1,5 +1,15 @@ # @moonshot-ai/kosong +## 0.4.4 + +### Patch Changes + +- [#632](https://github.com/MoonshotAI/kimi-code/pull/632) [`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. + +- [#658](https://github.com/MoonshotAI/kimi-code/pull/658) [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. + +- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. + ## 0.4.3 ### Patch Changes diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 15063979c..a9d884b08 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kosong", - "version": "0.4.3", + "version": "0.4.4", "private": true, "description": "The LLM abstraction layer for modern AI agent applications", "license": "MIT", diff --git a/packages/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index 96bf6bb9a..3bee845bb 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code-sdk +## 0.9.2 + +### Patch Changes + +- [#648](https://github.com/MoonshotAI/kimi-code/pull/648) [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. + ## 0.9.1 ### Patch Changes diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 12ea3735b..81ff4ab87 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code-sdk", - "version": "0.9.1", + "version": "0.9.2", "private": true, "description": "TypeScript SDK for the Kimi Code Agent", "license": "MIT", From dcf30754d09c7560101bc410387792194c3fe2b4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 12 Jun 2026 11:10:20 +0800 Subject: [PATCH 047/476] feat: stream shell tool output (#676) --- .changeset/shell-streaming-output.md | 6 ++ .../components/messages/shell-execution.ts | 8 +++ .../src/tui/components/messages/tool-call.ts | 57 ++++++++++++++++++- .../messages/tool-renderers/truncated.ts | 15 ++++- .../tui/controllers/session-event-handler.ts | 9 ++- .../tui/controllers/subagent-event-handler.ts | 6 ++ .../tui/components/messages/tool-call.test.ts | 42 ++++++++++++++ docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../src/tools/builtin/shell/bash.ts | 24 +++++--- packages/agent-core/test/agent/basic.test.ts | 1 + packages/agent-core/test/agent/config.test.ts | 1 + .../agent-core/test/agent/permission.test.ts | 2 + packages/agent-core/test/agent/plan.test.ts | 2 + packages/agent-core/test/agent/turn.test.ts | 1 + .../test/tools/shell-quoting.test.ts | 39 +++++++++++++ 16 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 .changeset/shell-streaming-output.md diff --git a/.changeset/shell-streaming-output.md b/.changeset/shell-streaming-output.md new file mode 100644 index 000000000..5d4fb7ada --- /dev/null +++ b/.changeset/shell-streaming-output.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Stream foreground Bash stdout and stderr while commands are still running. diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 06023d55d..1a28c7c64 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -20,6 +20,8 @@ export interface ShellExecutionOptions { */ readonly commandPreviewLines?: number; readonly resultPreviewLines?: number; + readonly tailOutput?: boolean; + readonly expandHint?: boolean; } export class ShellExecutionComponent extends Container { @@ -35,6 +37,8 @@ export class ShellExecutionComponent extends Container { options.result, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, + options.tailOutput ?? false, + options.expandHint ?? true, ); } } @@ -53,6 +57,8 @@ export class ShellExecutionComponent extends Container { result: ToolResultBlockData, expanded: boolean, previewLines: number, + tailOutput: boolean, + expandHint: boolean, ): void { if (!result.output) return; this.addChild( @@ -60,6 +66,8 @@ export class ShellExecutionComponent extends Container { expanded, isError: result.is_error ?? false, maxLines: previewLines, + tail: tailOutput, + expandHint, }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index f9f5db738..983d1bf8c 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -44,6 +44,7 @@ const STREAMING_PROGRESS_INTERVAL_MS = 1000; const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; +const MAX_LIVE_OUTPUT_CHARS = 50_000; type SubagentTextKind = 'thinking' | 'text'; type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; @@ -557,6 +558,7 @@ export class ToolCallComponent extends Container { // authoritative final state. private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + private liveOutput = ''; /** * Registered by a group container (`AgentGroupComponent` or @@ -586,6 +588,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); this.syncStreamingProgressTimer(); @@ -615,6 +618,7 @@ export class ToolCallComponent extends Container { // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; + this.liveOutput = ''; this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -657,6 +661,19 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendLiveOutput(text: string): void { + if (this.result !== undefined || text.length === 0) return; + this.liveOutput += text; + if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { + this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, + )}`; + } + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); @@ -1179,6 +1196,24 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendSubToolLiveOutput(id: string, text: string): void { + if (text.length === 0) return; + const activity = this.subToolActivities.get(id); + const ongoing = this.ongoingSubCalls.get(id); + if (activity === undefined && ongoing === undefined) return; + const name = activity?.name ?? ongoing?.name ?? 'Tool'; + const args = activity?.args ?? ongoing?.args ?? {}; + const existingOutput = activity?.output ?? ''; + let output = existingOutput + text; + if (output.length > MAX_LIVE_OUTPUT_CHARS) { + output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + } + this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + finishSubToolCall(result: { tool_call_id: string; output: string; @@ -1300,6 +1335,7 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1311,6 +1347,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1345,6 +1382,24 @@ export class ToolCallComponent extends Container { } } + private buildLiveOutputBlock(): void { + if (this.result !== undefined) return; + if (this.liveOutput.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + result: { + tool_call_id: this.toolCall.id, + output: this.liveOutput, + is_error: false, + }, + expanded: this.expanded, + resultPreviewLines: RESULT_PREVIEW_LINES, + tailOutput: true, + expandHint: false, + }), + ); + } + private buildSubagentBlock(): void { if ( this.subagentAgentId === undefined && @@ -1612,7 +1667,6 @@ export class ToolCallComponent extends Container { } private addSubToolOutputPreview(activity: SubToolActivity): void { - if (activity.phase === 'ongoing') return; const output = activity.output; if (output === undefined || output.trim().length === 0) return; // Mirror the main agent: Bash and any tool without a dedicated renderer @@ -1628,6 +1682,7 @@ export class ToolCallComponent extends Container { isError: activity.phase === 'failed', maxLines: RESULT_PREVIEW_LINES, indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, + tail: activity.phase === 'ongoing', }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 1933bfd50..0c1019068 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -30,6 +30,7 @@ export class TruncatedOutputComponent implements Component { private readonly maxLines: number; private readonly indent: number; private readonly expandHint: boolean; + private readonly tail: boolean; constructor( output: string, @@ -41,12 +42,16 @@ export class TruncatedOutputComponent implements Component { // When false, the truncation footer omits the "ctrl+o to expand" promise // (for contexts whose output is fixed-truncated and never expands). expandHint?: boolean; + // When true, collapsed rendering keeps the latest visual rows instead of + // the first rows. This is useful for live output from a running command. + tail?: boolean; }, ) { this.expanded = options.expanded; this.maxLines = options.maxLines ?? PREVIEW_LINES; this.indent = options.indent ?? DEFAULT_INDENT; this.expandHint = options.expandHint ?? true; + this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); this.textComponent = new Text( options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), @@ -67,8 +72,16 @@ export class TruncatedOutputComponent implements Component { return contentLines; } - const shown = contentLines.slice(0, this.maxLines); const remaining = contentLines.length - this.maxLines; + if (this.tail) { + const shown = contentLines.slice(contentLines.length - this.maxLines); + return [ + ' '.repeat(this.indent) + currentTheme.dim(`... (${String(remaining)} earlier lines)`), + ...shown, + ]; + } + + const shown = contentLines.slice(0, this.maxLines); const hint = this.expandHint ? `... (${String(remaining)} more lines, ctrl+o to expand)` : `... (${String(remaining)} more lines)`; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 09593c927..919568191 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -515,12 +515,17 @@ export class SessionEventHandler { } private handleToolProgress(event: ToolProgressEvent): void { - if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined) return; - tc.appendProgress(text); + if (event.update.kind === 'status') { + tc.appendProgress(text); + return; + } + if (event.update.kind === 'stdout' || event.update.kind === 'stderr') { + tc.appendLiveOutput(text); + } } private handleToolResult(event: ToolResultEvent): void { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 1fb2d71c5..6d08330c5 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -107,6 +107,12 @@ export class SubAgentEventHandler { name: event.name, argumentsPart: event.argumentsPart ?? null, }); + } else if ( + event.type === 'tool.progress' && + (event.update.kind === 'stdout' || event.update.kind === 'stderr') && + event.update.text !== undefined + ) { + toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text); } else if (event.type === 'tool.result') { toolCall.finishSubToolCall({ tool_call_id: `${childAgentId}:${event.toolCallId}`, diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 576947f3a..6d9147030 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -78,6 +78,48 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('ctrl+o to expand'); }); + it('renders live Bash output while the command is running', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('line1\n'); + component.appendLiveOutput('line2\n'); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Bash'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + + it('clears live Bash output when the final result arrives', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live_done', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('streamed-only\n'); + component.setResult({ + tool_call_id: 'call_shell_live_done', + output: 'final-only\n', + is_error: false, + }); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Used Bash'); + expect(out).toContain('final-only'); + expect(out).not.toContain('streamed-only'); + }); + it('hides tool output bodies that start with a { const reminderOutput = '\nThe task tools have not been used recently.\n'; diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index d2935ab03..497b4ea76 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -44,7 +44,7 @@ File tools handle reading, writing, and searching the local filesystem — the f - `description`: background task description; required when `run_in_background=true` - `disable_timeout`: whether to remove the timeout limit for background tasks -Foreground mode blocks the current turn until the command completes or times out; background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default. +Foreground mode blocks the current turn until the command completes or times out, and the TUI streams stdout and stderr into the running `Bash` tool card while the command is still active. Background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default. ## Web Tools diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 74fd9dbb5..eef9fc7fe 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -44,7 +44,7 @@ - `description`:后台任务描述,`run_in_background=true` 时必填 - `disable_timeout`:后台任务是否取消超时限制 -前台模式会阻塞当前轮次,直到命令结束或超时;后台模式立即返回任务 ID,任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。 +前台模式会阻塞当前轮次,直到命令结束或超时;命令运行期间,TUI 会把 stdout 和 stderr 流式显示在正在运行的 `Bash` 工具卡片中。后台模式立即返回任务 ID,任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。 ## 网络类 diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index a176678e8..4b131446a 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -31,7 +31,7 @@ import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; -import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; @@ -181,7 +181,7 @@ export class BashTool implements BuiltinTool { }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), - execute: ({ signal }) => this.execution(args, signal), + execute: ({ signal, onUpdate }) => this.execution(args, signal, onUpdate), }; } @@ -212,7 +212,11 @@ export class BashTool implements BuiltinTool { return this.kaos.execWithEnv(shellArgs, mergedEnv); } - private async execution(args: BashInput, signal: AbortSignal): Promise { + private async execution( + args: BashInput, + signal: AbortSignal, + onUpdate?: ((update: ToolUpdate) => void) | undefined, + ): Promise { if (signal.aborted) { return { isError: true, output: 'Aborted before command started' }; } @@ -312,8 +316,8 @@ export class BashTool implements BuiltinTool { const isTerminating = (): boolean => timedOut || aborted || killed; const [, exitCode] = await Promise.all([ Promise.all([ - readStreamIntoBuilder(proc.stdout, builder, isTerminating), - readStreamIntoBuilder(proc.stderr, builder, isTerminating), + readStreamIntoBuilder(proc.stdout, builder, 'stdout', onUpdate, isTerminating), + readStreamIntoBuilder(proc.stderr, builder, 'stderr', onUpdate, isTerminating), ]), proc.wait(), ]); @@ -439,6 +443,8 @@ export class BashTool implements BuiltinTool { async function readStreamIntoBuilder( stream: Readable, builder: ToolResultBuilder, + kind: 'stdout' | 'stderr', + onUpdate?: ((update: ToolUpdate) => void) | undefined, suppressPrematureClose?: () => boolean, ): Promise { const decoder = new StringDecoder('utf8'); @@ -446,14 +452,18 @@ async function readStreamIntoBuilder( for await (const chunk of stream) { const buf: Buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); - builder.write(decoder.write(buf)); + const text = decoder.write(buf); + if (text.length > 0) onUpdate?.({ kind, text }); + builder.write(text); } } catch (error) { if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) { throw error; } } - builder.write(decoder.end()); + const trailing = decoder.end(); + if (trailing.length > 0) onUpdate?.({ kind, text: trailing }); + builder.write(trailing); } function isPrematureCloseError(error: unknown): boolean { diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 6e947631e..1c9bfec61 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -119,6 +119,7 @@ it('runs an agent turn through builtin tool approval and execution', async () => [wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf lookup-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "