diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 2fdc84040..15471a032 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -59,13 +59,26 @@ If you believe a change qualifies as major, stop first, explain why, and ask the ## Wording Rules - Changelog entries **must be written in English**. -- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors. + - Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.` + - CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.` + - Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.` + - Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo , and writes the result to the transcript...` - User-facing CLI wording should only be used when CLI users can perceive the change. - Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. - Do not mention file names, class names, function names, PR numbers, or commit hashes. - Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`. - Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. +## When You Are Unsure About a Change + +Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording. + +1. Finish the changeset for the parts that are clear. +2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately. +3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail. + ## Common Examples An internal package fixes a bug visible to CLI users: @@ -78,6 +91,36 @@ An internal package fixes a bug visible to CLI users: Fix occasional loss of tool call results in long conversations. ``` +A new user-facing slash command (note the short usage hint): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /foo slash command to list active sessions. Run /foo to see them. +``` + +A new CLI subcommand: + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the kimi web subcommand to open the web UI. Run kimi web to launch it. +``` + +A new flag on an existing command: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a --bar flag to skip confirmation prompts. Pass --bar to skip. +``` + An internal package has an internal-only change, but it enters the CLI bundle: ```markdown @@ -136,6 +179,8 @@ Add the server REST and WebSocket APIs that power the web UI. ## Red Flags - You are about to write `major` without asking the user. +- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale. +- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo. - Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing. - A changeset frontmatter mixes ignored internal packages with non-ignored packages. - `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync". diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index f99ca2195..d4af78708 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -231,6 +231,51 @@ Chinese page requirements: - Translate only entry body text. Do not add entries that are not present in English. - Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary. +#### Chinese wording style + +Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose. + +Guidelines: + +- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect. +- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. +- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. + - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` + - Better: `缓存已渲染消息行,提升长对话下终端的响应速度。` +- **Be specific, not vague**. Prefer concrete actions over abstract quality words. + - Bad: `加固默认系统提示词和内置工具描述。` + - Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。` +- **Name concrete files or config keys when it helps clarity**. + - Bad: `插件现在可以在其清单中声明 hooks。` + - Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。` +- **Include required argument placeholders in CLI options**. + - Bad: `--allowed-host` + - Better: `--allowed-host ` +- **Keep usage hints to one short clause**. + - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` + - Better: `例如 kimi web --allowed-host example.com。` +- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, and config keys as-is. + +Example — translating a feature entry: + +English source: + +```markdown +- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. +``` + +Before (literal, wordy): + +```markdown +- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host ` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。 +``` + +After (concise, idiomatic): + +```markdown +- `kimi web` 新增 `--allowed-host ` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。 +``` + ### 7. Verify Review: diff --git a/.changeset/add-gui-store-api.md b/.changeset/add-gui-store-api.md new file mode 100644 index 000000000..0bca72941 --- /dev/null +++ b/.changeset/add-gui-store-api.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/protocol": patch +"@moonshot-ai/server": patch +"@moonshot-ai/kimi-code": patch +--- + +Add a server-side key-value store API for persisting web UI preferences to the user's data directory. diff --git a/.changeset/double-esc-undo.md b/.changeset/double-esc-undo.md new file mode 100644 index 000000000..a48970c97 --- /dev/null +++ b/.changeset/double-esc-undo.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo. diff --git a/.changeset/fix-at-mention-slash-arg.md b/.changeset/fix-at-mention-slash-arg.md new file mode 100644 index 000000000..64c373cd0 --- /dev/null +++ b/.changeset/fix-at-mention-slash-arg.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix @ file mentions not opening when typed inside a slash command argument. diff --git a/.changeset/fix-mcp-web-cwd.md b/.changeset/fix-mcp-web-cwd.md deleted file mode 100644 index 4b7f114d4..000000000 --- a/.changeset/fix-mcp-web-cwd.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix MCP server working directories when sessions are hosted by the web server. diff --git a/.changeset/fix-shell-mode-slash-completion.md b/.changeset/fix-shell-mode-slash-completion.md new file mode 100644 index 000000000..9078ab1d2 --- /dev/null +++ b/.changeset/fix-shell-mode-slash-completion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show file path completions when typing `/` in shell mode (`!`). diff --git a/.changeset/fix-skill-listing-truncation.md b/.changeset/fix-skill-listing-truncation.md deleted file mode 100644 index 08cc8abe9..000000000 --- a/.changeset/fix-skill-listing-truncation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix truncated skill descriptions missing an ellipsis in the model's skill listing. diff --git a/.changeset/fix-web-duplicate-workspaces.md b/.changeset/fix-web-duplicate-workspaces.md new file mode 100644 index 000000000..b389fcae7 --- /dev/null +++ b/.changeset/fix-web-duplicate-workspaces.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once. diff --git a/.changeset/plugin-commands.md b/.changeset/plugin-commands.md new file mode 100644 index 000000000..35e3b02e7 --- /dev/null +++ b/.changeset/plugin-commands.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Plugins can now provide slash commands via a `commands` field in their manifest, registered as `:` and invoked with `$ARGUMENTS` expansion. diff --git a/.changeset/plugin-marketplace-tabs.md b/.changeset/plugin-marketplace-tabs.md deleted file mode 100644 index a4e269955..000000000 --- a/.changeset/plugin-marketplace-tabs.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed -plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained -marketplace plugins), **Third-party** (marketplace plugins from other -publishers), and **Custom** (install straight from a GitHub URL, zip URL, or -local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party -catalogs load lazily, so `/plugins` opens instantly and keeps working offline — -a marketplace fetch failure is shown inline instead of closing the panel. The -tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` -helper. diff --git a/.changeset/soft-agents-md-warning.md b/.changeset/soft-agents-md-warning.md deleted file mode 100644 index c9dd4b7a1..000000000 --- a/.changeset/soft-agents-md-warning.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI. diff --git a/.changeset/ttft-client-server-split.md b/.changeset/ttft-client-server-split.md new file mode 100644 index 000000000..3e8b5c7ef --- /dev/null +++ b/.changeset/ttft-client-server-split.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Split LLM streaming timing in the session log and `KIMI_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk. diff --git a/.changeset/web-telemetry-toggle.md b/.changeset/web-telemetry-toggle.md new file mode 100644 index 000000000..34165369d --- /dev/null +++ b/.changeset/web-telemetry-toggle.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Always show the usage-data opt-out toggle in the web settings with a clearer label and description. diff --git a/.changeset/write-auto-mkdir-parents.md b/.changeset/write-auto-mkdir-parents.md deleted file mode 100644 index 9c385bb47..000000000 --- a/.changeset/write-auto-mkdir-parents.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Create missing parent directories automatically when writing a file. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b3726523e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Enforce LF line endings in the working tree on every platform so that +# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows +# and POSIX. Without this, Git for Windows' default `core.autocrlf=true` +# checks text files out as CRLF, which shifts token-count snapshots. +* text=auto eol=lf + +# Binary assets — never normalize line endings. +*.gif binary +*.ico binary +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 254ca51cc..ebbc4897f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,27 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run test + test-windows: + runs-on: windows-latest + # Temporarily disabled while Windows tests are being stabilized. + if: false + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + # Windows runners are slower and run the whole suite (including + # in-process e2e tests) under more contention, so the default 5s test + # timeout causes flaky failures. Give it more headroom. + - run: pnpm run test -- --testTimeout=30000 + lint: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 43441a154..2f3246b13 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,9 @@ coverage/ .conductor .kimi-stash-dir plugins/cdn/ -superpowers .worktrees/ .kimi-code/local.toml +.kimi-sandbox/ Dockerfile docker-compose.yml diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..877553f49 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,6 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", "*.generated.ts" ] } diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 2928c785b..c551da9cc 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,187 @@ # @moonshot-ai/kimi-code +## 0.20.3 + +### Patch Changes + +- [#1207](https://github.com/MoonshotAI/kimi-code/pull/1207) [`14d9e98`](https://github.com/MoonshotAI/kimi-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting. + +- [#1191](https://github.com/MoonshotAI/kimi-code/pull/1191) [`0df1812`](https://github.com/MoonshotAI/kimi-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page. + +- [#1212](https://github.com/MoonshotAI/kimi-code/pull/1212) [`636ccc4`](https://github.com/MoonshotAI/kimi-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused. + +- [#1068](https://github.com/MoonshotAI/kimi-code/pull/1068) [`c82dcf9`](https://github.com/MoonshotAI/kimi-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable. + +- [#1209](https://github.com/MoonshotAI/kimi-code/pull/1209) [`0635387`](https://github.com/MoonshotAI/kimi-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback. + +## 0.20.2 + +### Patch Changes + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages. + +- [#1156](https://github.com/MoonshotAI/kimi-code/pull/1156) [`794db55`](https://github.com/MoonshotAI/kimi-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors. + +- [#1129](https://github.com/MoonshotAI/kimi-code/pull/1129) [`d02b5c4`](https://github.com/MoonshotAI/kimi-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size. + +- [#1188](https://github.com/MoonshotAI/kimi-code/pull/1188) [`db5fbc5`](https://github.com/MoonshotAI/kimi-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel. + +- [#1187](https://github.com/MoonshotAI/kimi-code/pull/1187) [`97f9263`](https://github.com/MoonshotAI/kimi-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn. + +- [#1163](https://github.com/MoonshotAI/kimi-code/pull/1163) [`ff6e8bb`](https://github.com/MoonshotAI/kimi-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session. + +- [#1189](https://github.com/MoonshotAI/kimi-code/pull/1189) [`04b3492`](https://github.com/MoonshotAI/kimi-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent swarm progress bar. + +- [#1159](https://github.com/MoonshotAI/kimi-code/pull/1159) [`23a553b`](https://github.com/MoonshotAI/kimi-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Kimi Code, including video input. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry. + +- [#1155](https://github.com/MoonshotAI/kimi-code/pull/1155) [`54baf5d`](https://github.com/MoonshotAI/kimi-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements. + +- [#1162](https://github.com/MoonshotAI/kimi-code/pull/1162) [`b070846`](https://github.com/MoonshotAI/kimi-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see. + +- [#1179](https://github.com/MoonshotAI/kimi-code/pull/1179) [`fc3d69d`](https://github.com/MoonshotAI/kimi-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. + +- [#1165](https://github.com/MoonshotAI/kimi-code/pull/1165) [`f3b1532`](https://github.com/MoonshotAI/kimi-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon. + +- [#1167](https://github.com/MoonshotAI/kimi-code/pull/1167) [`c63edd5`](https://github.com/MoonshotAI/kimi-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message. + +- [#1161](https://github.com/MoonshotAI/kimi-code/pull/1161) [`d968642`](https://github.com/MoonshotAI/kimi-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions. + +## 0.20.1 + +### Patch Changes + +- [#1125](https://github.com/MoonshotAI/kimi-code/pull/1125) [`e9a3b7c`](https://github.com/MoonshotAI/kimi-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `kimi upgrade` command. Run `kimi update` to upgrade to the latest version. + +- [#1122](https://github.com/MoonshotAI/kimi-code/pull/1122) [`820d77a`](https://github.com/MoonshotAI/kimi-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel. + +- [#1131](https://github.com/MoonshotAI/kimi-code/pull/1131) [`76c643b`](https://github.com/MoonshotAI/kimi-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors. + +- [#1120](https://github.com/MoonshotAI/kimi-code/pull/1120) [`e736349`](https://github.com/MoonshotAI/kimi-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context. + +- [#1135](https://github.com/MoonshotAI/kimi-code/pull/1135) [`bf51fb7`](https://github.com/MoonshotAI/kimi-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive. + +- [#1102](https://github.com/MoonshotAI/kimi-code/pull/1102) [`9c97161`](https://github.com/MoonshotAI/kimi-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed. + +- [#1127](https://github.com/MoonshotAI/kimi-code/pull/1127) [`184acf5`](https://github.com/MoonshotAI/kimi-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events. + +- [#1128](https://github.com/MoonshotAI/kimi-code/pull/1128) [`0886bff`](https://github.com/MoonshotAI/kimi-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to kimi server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn. + +- [#1121](https://github.com/MoonshotAI/kimi-code/pull/1121) [`81ba48f`](https://github.com/MoonshotAI/kimi-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages. + +- [#1133](https://github.com/MoonshotAI/kimi-code/pull/1133) [`f1c8175`](https://github.com/MoonshotAI/kimi-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits. + +## 0.20.0 + +### Minor Changes + +- [#1079](https://github.com/MoonshotAI/kimi-code/pull/1079) [`2db5fc2`](https://github.com/MoonshotAI/kimi-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands. + Type `!` in the input box to enable it. + The command output is visible to the AI. + For long-running commands, press Ctrl+B to move them to the background. + For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`. + +- [#1088](https://github.com/MoonshotAI/kimi-code/pull/1088) [`0030f76`](https://github.com/MoonshotAI/kimi-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins. + +- [#1066](https://github.com/MoonshotAI/kimi-code/pull/1066) [`3554f7e`](https://github.com/MoonshotAI/kimi-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. + +- [#1025](https://github.com/MoonshotAI/kimi-code/pull/1025) [`5ef66dd`](https://github.com/MoonshotAI/kimi-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed + plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained + marketplace plugins), **Third-party** (marketplace plugins from other + publishers), and **Custom** (install straight from a GitHub URL, zip URL, or + local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party + catalogs load lazily, so `/plugins` opens instantly and keeps working offline — + a marketplace fetch failure is shown inline instead of closing the panel. The + tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` + helper. + +- [#1006](https://github.com/MoonshotAI/kimi-code/pull/1006) [`60dfb68`](https://github.com/MoonshotAI/kimi-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now + requires a per-start bearer token on all API and WebSocket calls (the CLI reads + it automatically), enforces Host/Origin checks, and gains `--host` with a + public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or + `--insecure-no-tls`), auth-failure rate limiting, disabled remote + shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`. + +- [#1040](https://github.com/MoonshotAI/kimi-code/pull/1040) [`6664038`](https://github.com/MoonshotAI/kimi-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI. + +- [#1101](https://github.com/MoonshotAI/kimi-code/pull/1101) [`3ea6ac2`](https://github.com/MoonshotAI/kimi-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI. + +- [#1103](https://github.com/MoonshotAI/kimi-code/pull/1103) [`18f7c34`](https://github.com/MoonshotAI/kimi-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat. + +### Patch Changes + +- [#1072](https://github.com/MoonshotAI/kimi-code/pull/1072) [`a86bb97`](https://github.com/MoonshotAI/kimi-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint. + +- [#1076](https://github.com/MoonshotAI/kimi-code/pull/1076) [`500677a`](https://github.com/MoonshotAI/kimi-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately. + +- [#1067](https://github.com/MoonshotAI/kimi-code/pull/1067) [`0e227ba`](https://github.com/MoonshotAI/kimi-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. + +- [#1075](https://github.com/MoonshotAI/kimi-code/pull/1075) [`3aaf1e5`](https://github.com/MoonshotAI/kimi-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error. + +- [#1094](https://github.com/MoonshotAI/kimi-code/pull/1094) [`8ee5c0f`](https://github.com/MoonshotAI/kimi-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input. + +- [#1057](https://github.com/MoonshotAI/kimi-code/pull/1057) [`ee69e16`](https://github.com/MoonshotAI/kimi-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server. + +- [#1064](https://github.com/MoonshotAI/kimi-code/pull/1064) [`a752a53`](https://github.com/MoonshotAI/kimi-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing. + +- [#903](https://github.com/MoonshotAI/kimi-code/pull/903) [`bbd8a1a`](https://github.com/MoonshotAI/kimi-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected. + +- [#1070](https://github.com/MoonshotAI/kimi-code/pull/1070) [`ff17715`](https://github.com/MoonshotAI/kimi-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer. + +- [#1097](https://github.com/MoonshotAI/kimi-code/pull/1097) [`27ef516`](https://github.com/MoonshotAI/kimi-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option. + +- [#1062](https://github.com/MoonshotAI/kimi-code/pull/1062) [`ea6a4bf`](https://github.com/MoonshotAI/kimi-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. + +- [#1086](https://github.com/MoonshotAI/kimi-code/pull/1086) [`fe667d7`](https://github.com/MoonshotAI/kimi-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one. + +- [#1081](https://github.com/MoonshotAI/kimi-code/pull/1081) [`8fc6aa5`](https://github.com/MoonshotAI/kimi-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode. + +- [#1078](https://github.com/MoonshotAI/kimi-code/pull/1078) [`75ca3b2`](https://github.com/MoonshotAI/kimi-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer. + +- [#1069](https://github.com/MoonshotAI/kimi-code/pull/1069) [`d18aa16`](https://github.com/MoonshotAI/kimi-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks. + +- [#1112](https://github.com/MoonshotAI/kimi-code/pull/1112) [`6a97d0b`](https://github.com/MoonshotAI/kimi-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat. + +- [#1035](https://github.com/MoonshotAI/kimi-code/pull/1035) [`ea03f30`](https://github.com/MoonshotAI/kimi-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula. + +- [#1084](https://github.com/MoonshotAI/kimi-code/pull/1084) [`d6e5246`](https://github.com/MoonshotAI/kimi-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front. + +- [#1113](https://github.com/MoonshotAI/kimi-code/pull/1113) [`6194d3f`](https://github.com/MoonshotAI/kimi-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The + event reducer now reuses the `sessions` array reference for events that do not + change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups` + / `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events. + +- [#1087](https://github.com/MoonshotAI/kimi-code/pull/1087) [`884b65a`](https://github.com/MoonshotAI/kimi-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync. + +- [#1109](https://github.com/MoonshotAI/kimi-code/pull/1109) [`d554f9a`](https://github.com/MoonshotAI/kimi-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. + +- [#1065](https://github.com/MoonshotAI/kimi-code/pull/1065) [`4b837d6`](https://github.com/MoonshotAI/kimi-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file. + ## 0.19.2 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d60576277..e81ead095 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.19.2", + "version": "0.20.3", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -74,7 +74,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2", + "@mariozechner/clipboard": "^0.3.9", "koffi": "^2.16.0", "node-pty": "^1.1.0" }, diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2ca5dadd7..32a65eb0e 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -96,6 +96,7 @@ export function createProgram( registerMigrateCommand(program, onMigrate); program .command('upgrade') + .alias('update') .description('Upgrade Kimi Code to the latest version.') .action(async () => { await onUpgrade(); diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 0823c5548..79a301ba1 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -78,10 +78,10 @@ export async function runPrompt( telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { - track('oauth_refresh', { success: true }); + track('oauth_refresh', { outcome: 'success' }); return; } - track('oauth_refresh', { success: false, reason: outcome.reason }); + track('oauth_refresh', { outcome: 'error', reason: outcome.reason }); }, sessionStartedProperties: { yolo: false, plan: false, afk: true }, }); @@ -153,7 +153,7 @@ export async function runPrompt( writeResumeHint(session.id, outputFormat, stdout, stderr); withTelemetryContext({ sessionId: session.id }).track('exit', { - duration_s: (Date.now() - startedAt) / 1000, + duration_ms: Date.now() - startedAt, }); } finally { await cleanupPromptRun(); diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index bd35ddb9d..1bd55a84b 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -64,11 +64,11 @@ export async function runShell( telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { - track('oauth_refresh', { success: true }); + track('oauth_refresh', { outcome: 'success' }); return; } track('oauth_refresh', { - success: false, + outcome: 'error', reason: outcome.reason, }); }, @@ -137,7 +137,7 @@ export async function runShell( const sessionId = tui.getCurrentSessionId(); const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); @@ -172,7 +172,7 @@ export async function runShell( }); } catch (error) { setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts new file mode 100644 index 000000000..0c6233fe8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -0,0 +1,85 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `server run` ready banner and `server rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 1c840d418..84070a736 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -16,8 +16,8 @@ * foreground runner so it can share the same bootstrap helpers. */ -import { spawn } from 'node:child_process'; -import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -27,6 +27,7 @@ import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/s import { DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, isServerHealthy, serverOrigin, waitForServerHealthy, @@ -44,18 +45,34 @@ const POLL_INTERVAL_MS = 200; const DEFAULT_DAEMON_LOG_LEVEL = 'info'; export interface EnsureDaemonOptions { + /** Bind host for the spawned daemon (default `127.0.0.1`). */ + host?: string; /** Preferred port; on conflict a free port is chosen automatically. */ port?: number; /** Pino log level for the spawned daemon (defaults to `info`). */ logLevel?: string; /** Mount `/api/v1/debug/*` routes on the spawned daemon. */ debugEndpoints?: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls?: boolean; + /** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */ + allowRemoteShutdown?: boolean; + /** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */ + allowRemoteTerminals?: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts?: readonly string[]; /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ idleGraceMs?: number; } export interface EnsureDaemonResult { readonly origin: string; + /** True when an already-running daemon was reused (no new server started). */ + readonly reused: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + readonly host: string; + /** Port the running daemon is actually listening on (from the lock). */ + readonly port: number; } /** Path of the daemon log file (shared with the OS-service log location). */ @@ -64,8 +81,8 @@ export function daemonLogPath(): string { } export function lockConnectHost(lock: LockContents): string { - const host = lock.host ?? DEFAULT_SERVER_HOST; - return host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host; + const host = lock.host ?? LOCAL_SERVER_HOST; + return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host; } /** True when `host:port` is currently free to bind (nothing listening). */ @@ -178,13 +195,18 @@ export function resolveDaemonProgram( } interface SpawnDaemonChildOptions { + host?: string; port: number; logLevel: string; debugEndpoints?: boolean; + insecureNoTls?: boolean; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; + allowedHosts?: readonly string[]; idleGraceMs?: number; } -export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { +export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { const program = resolveDaemonProgram(); const logPath = daemonLogPath(); const logDir = dirname(logPath); @@ -198,15 +220,37 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { '--log-level', options.logLevel, ]; + if (options.host !== undefined) { + args.push('--host', options.host); + } if (options.debugEndpoints === true) { args.push('--debug-endpoints'); } + if (options.insecureNoTls === true) { + args.push('--insecure-no-tls'); + } + if (options.allowRemoteShutdown === true) { + args.push('--allow-remote-shutdown'); + } + if (options.allowRemoteTerminals === true) { + args.push('--allow-remote-terminals'); + } if (options.idleGraceMs !== undefined) { args.push('--idle-grace-ms', String(options.idleGraceMs)); } + if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) { + args.push('--allowed-host', ...options.allowedHosts); + } + // On Windows `.mjs` files are not executable PE binaries, so we must run + // the script through the Node binary rather than spawning it directly. In + // SEA mode or when re-spawning from an already-running daemon, `program` is + // `process.execPath` itself, so no script argument is needed. + const execPath = process.execPath; + const spawnArgs = program === execPath ? args : [program, ...args]; + const logFd = openSync(logPath, 'a'); try { - const child = spawn(program, args, { + const child = spawn(execPath, spawnArgs, { detached: true, // Run from the server log directory instead of inheriting the caller's // cwd, so the long-lived daemon does not pin the directory it was @@ -226,6 +270,7 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { } }); child.unref(); + return child; } finally { // `spawn` dups the fd into the child; the parent must not keep it open. closeSync(logFd); @@ -244,6 +289,7 @@ function sleep(ms: number): Promise { * detached process after this returns. */ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; const preferred = options.port ?? DEFAULT_SERVER_PORT; const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; @@ -252,7 +298,12 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + childExit = { code, signal }; + }); + child.once('error', () => { + // Spawn failure (ENOENT etc.) is already recorded in the log by + // spawnDaemonChild; treat it as an early exit here. + childExit = { code: -1, signal: null }; + }); + // 3. Wait until some live daemon (ours, or a racer that won the lock) is up. const deadline = Date.now() + SPAWN_TIMEOUT_MS; while (Date.now() < deadline) { @@ -275,14 +346,53 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise line.length > 0); + return lines.slice(-maxLines).join('\n'); + } catch { + return ''; + } +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts index c17a61d18..b8e9a5ffa 100644 --- a/apps/kimi-code/src/cli/sub/server/index.ts +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -17,6 +17,7 @@ import type { Command } from 'commander'; import { registerPsCommand } from './ps'; import { registerKillCommand } from './kill'; import { buildRunCommand } from './run'; +import { registerRotateTokenCommand } from './rotate-token'; import { registerWebAliasCommand } from './web-alias'; export function registerServerCommand(program: Command): void { @@ -33,6 +34,8 @@ export function registerServerCommand(program: Command): void { registerKillCommand(server); + registerRotateTokenCommand(server); + // OS service-manager commands (`install/uninstall/start/stop/restart/status`) // are temporarily hidden — the product now favors the on-demand background // daemon (`kimi web`) over service-ization. The implementation still lives in diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts index 7275991e5..71b4f2e44 100644 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -18,8 +18,10 @@ import type { Command } from 'commander'; import { getLiveLock, type LockContents } from '@moonshot-ai/server'; +import { getDataDir } from '#/utils/paths'; + import { lockConnectHost } from './daemon'; -import { serverOrigin } from './shared'; +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; /** How long to wait for the graceful API shutdown request. */ const API_TIMEOUT_MS = 2000; @@ -32,7 +34,9 @@ const POLL_INTERVAL_MS = 100; export interface KillCommandDeps { getLiveLock(): LockContents | undefined; - requestShutdown(origin: string): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; signalPid(pid: number, signal: NodeJS.Signals): boolean; pidAlive(pid: number): boolean; sleep(ms: number): Promise; @@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise { // 1. API path — best-effort graceful shutdown. Ignore every outcome: the // server may be an older build without the route, already wedged, or may - // drop the connection as it exits. - await deps.requestShutdown(origin).catch(() => {}); + // drop the connection as it exits. The bearer token (M5.1) is best-effort + // too: if it can't be read the API call 401s and the PID path below still + // guarantees the kill. + const token = deps.resolveToken(); + await deps.requestShutdown(origin, token).catch(() => {}); // 2. PID path — SIGTERM, wait, then SIGKILL. deps.signalPid(pid, 'SIGTERM'); @@ -126,7 +133,10 @@ export function signalPid(pid: number, signal: NodeJS.Signals): boolean { } /** POST the shutdown endpoint; resolves once the request completes or times out. */ -export async function requestShutdownViaApi(origin: string): Promise { +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); @@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise { try { await fetch(`${origin}/api/v1/shutdown`, { method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, signal: controller.signal, }); } finally { @@ -144,6 +155,7 @@ export async function requestShutdownViaApi(origin: string): Promise { const DEFAULT_KILL_DEPS: KillCommandDeps = { getLiveLock, requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), signalPid, pidAlive, sleep: (ms) => diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts index eb1349c99..f7ff072d8 100644 --- a/apps/kimi-code/src/cli/sub/server/lifecycle.ts +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -25,6 +25,7 @@ import { DEFAULT_LOG_LEVEL, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, parseLogLevel, parsePort, serverOrigin, @@ -249,5 +250,5 @@ function withStatusDetails( } function formatServiceUrl(host: string, port: number): string { - return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port); + return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port); } diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/server/networks.ts new file mode 100644 index 000000000..39ca7d084 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts index 7db750545..6e17d71c0 100644 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -11,8 +11,10 @@ import type { Command } from 'commander'; import { getLiveLock } from '@moonshot-ai/server'; +import { getDataDir } from '#/utils/paths'; + import { lockConnectHost } from './daemon'; -import { isServerHealthy, serverOrigin } from './shared'; +import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; /** Wire shape of a single connection returned by `GET /api/v1/connections`. */ interface ConnectionInfo { @@ -62,7 +64,11 @@ async function handlePsCommand(opts: { json?: boolean }): Promise { throw new Error(`Kimi server at ${origin} is not responding.`); } - const connections = await fetchConnections(origin); + // The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the + // persistent token; a clear error here means the server has never been + // started (no token file yet) or the token file was removed. + const token = resolveServerToken(getDataDir()); + const connections = await fetchConnections(origin, token); if (opts.json) { process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); @@ -71,13 +77,14 @@ async function handlePsCommand(opts: { json?: boolean }): Promise { process.stdout.write(formatTable(connections)); } -async function fetchConnections(origin: string): Promise { +async function fetchConnections(origin: string, token: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, FETCH_TIMEOUT_MS); try { const res = await fetch(`${origin}/api/v1/connections`, { + headers: authHeaders(token), signal: controller.signal, }); if (!res.ok) { diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts new file mode 100644 index 000000000..8f9a47970 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -0,0 +1,57 @@ +/** + * `kimi server rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveLock, rotateServerToken } from '@moonshot-ai/server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; +import { DEFAULT_SERVER_HOST } from './shared'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the lock; otherwise there is nothing to connect to yet. + const lock = getLiveLock(); + if (lock !== undefined) { + const host = lock.host ?? DEFAULT_SERVER_HOST; + for (const { label, url: href } of accessUrlLines(host, lock.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index c556f9f5a..2ebb04cd0 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -13,31 +13,40 @@ import { join } from 'node:path'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import { startServer, type RunningServer } from '@moonshot-ai/server'; import chalk from 'chalk'; import { Option, type Command } from 'commander'; -import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; import { darkColors } from '#/tui/theme/colors'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; -import { ensureDaemon } from './daemon'; +import { + accessUrlLines, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { ensureDaemon, type EnsureDaemonResult } from './daemon'; +import { type NetworkAddress } from './networks'; import { DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_LAN_HOST, + DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, parseServerOptions, + tryResolveServerToken, VALID_LOG_LEVELS, type ParsedServerOptions, type ServerCliOptions, } from './shared'; const WEB_ASSETS_DIR = 'dist-web'; -const READY_PANEL_WIDTH = 72; export interface RunCliOptions extends ServerCliOptions { open?: boolean; @@ -51,17 +60,49 @@ export interface StartForegroundHooks { } export interface RunCommandDeps { - startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>; + startServerBackground(options: ParsedServerOptions): Promise<{ + origin: string; + /** True when an already-running daemon was reused (no new server started). */ + reused?: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + host?: string; + /** Port the running daemon is actually listening on (from the lock). */ + port?: number; + }>; /** Foreground runner; defaults to the real in-process runner when omitted. */ startServerForeground?: ( options: ParsedServerOptions, hooks?: StartForegroundHooks, ) => Promise; openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; stdout: Pick; stderr: Pick; } +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + /** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { return cmd @@ -70,6 +111,29 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT), ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', + true, + ) + .option( + '--allow-remote-shutdown', + 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', + false, + ) + .option( + '--allow-remote-terminals', + 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', + false, + ) .option( '--log-level ', `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, @@ -119,34 +183,55 @@ export async function handleRunCommand( await startServerDaemon(parsed); return; } - const startedAt = Date.now(); + // Resolve the persistent token once: it is printed in the ready banner and + // rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to + // the plain origin / no token line when unavailable. + const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => { + const { origin } = result; + const host = result.host ?? parsed.host; + const token = deps.resolveToken?.(); + let output = ''; + if (result.reused === true) { + // A daemon was already running, so this command's --host/--port/etc. did + // not start a new one. Say so loudly, then print the actual running + // server's URLs (using its real bind host, not the requested one). + output += formatReuseNotice(origin); + } + output += + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, host, { + token, + networkAddresses: deps.networkAddresses, + }) + : formatReadyLine(origin, token); + deps.stdout.write(output); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }; if (opts.foreground === true) { const run = deps.startServerForeground ?? startServerForeground; await run(parsed, { onReady: (origin) => { - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Kimi server: ${origin}\n`, - ); - if (opts.open === true) { - deps.openUrl(origin); - } + writeReady({ origin, reused: false, host: parsed.host }); }, }); return; } - const { origin } = await deps.startServerBackground(parsed); - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Kimi server: ${origin}\n`, + const result = await deps.startServerBackground(parsed); + writeReady(result); +} + +function formatReuseNotice(origin: string): string { + return ( + `${chalk.hex(darkColors.warning)('A server is already running')} at ${origin} — ` + + `the options from this command were not applied. ` + + `Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n` ); - if (opts.open === true) { - deps.openUrl(origin); - } +} + +function formatReadyLine(origin: string, token: string | undefined): string { + return `Kimi server: ${buildOpenableUrl(origin, token)}\n`; } /** @@ -157,14 +242,18 @@ export async function handleRunCommand( */ export async function startServerBackground( options: ParsedServerOptions, -): Promise<{ origin: string }> { - const { origin } = await ensureDaemon({ +): Promise { + return ensureDaemon({ + host: options.host, port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, idleGraceMs: options.idleGraceMs, }); - return { origin }; } /** @@ -238,6 +327,10 @@ async function runServerInProcess( port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { identity: createKimiCodeHostIdentity(version), @@ -253,7 +346,7 @@ async function runServerInProcess( }, }); - track('server_started', { ui_mode: WEB_UI_MODE, daemon: mode.daemon }); + track('server_started', { daemon: mode.daemon }); process.once('SIGINT', () => { void shutdown('SIGINT'); @@ -323,65 +416,80 @@ export function resolveServerWebAssetsDir( return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } -function formatReadyBanner(origin: string, readyMs: number): string { +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; +} + +function formatReadyBanner( + origin: string, + host: string, + opts: FormatReadyBannerOptions = {}, +): string { const primary = (text: string): string => chalk.hex(darkColors.primary)(text); const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); - const url = chalk.hex(darkColors.accent)(displayOrigin(origin)); - const width = READY_PANEL_WIDTH; - const innerWidth = width - 4; - const pad = ' '; + const url = (text: string): string => chalk.hex(darkColors.accent)(text); + // Render the `#token=…` fragment in a de-emphasized gray so the host/port + // stands out while the full URL stays selectable for copying. + const urlWithDimToken = (href: string): string => { + const [base, frag] = splitTokenFragment(href); + return frag === '' ? url(base) : url(base) + dim(frag); + }; + const port = Number(new URL(origin).port); + // Borderless header: the Kimi sprite (the little mascot with eyes) sits next + // to the title, keeping the brand without the enclosing box. const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; - const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); - const gap = ' '; - const textWidth = innerWidth - logoWidth - gap.length; - const headerLines = [ - primary(logo[0].padEnd(logoWidth)) + - gap + - truncateToWidth(title('Kimi server ready'), textWidth, '…'), - primary(logo[1].padEnd(logoWidth)) + - gap + - truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'), - ]; - const infoLines = [ - label('URL: ') + url, - label('Network: ') + muted('local only'), - label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), - label('Stop: ') + muted('kimi server kill'), - label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), - label('Version: ') + muted(getVersion()), - ]; - const contentLines = [...headerLines, '', ...infoLines]; - - const lines = [ + const lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, '', - primary('╭' + '─'.repeat(width - 2) + '╮'), - primary('│') + ' '.repeat(width - 2) + primary('│'), ]; - for (const content of contentLines) { - const truncated = truncateToWidth(content, innerWidth, '…'); - const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + // Access links. + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${urlWithDimToken(href)}`); + } + // On a loopback bind there is no network URL; show how to enable one. + if (isLoopbackHost(host)) { + lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`); + } + if (opts.token !== undefined) { + // Set the token off with surrounding whitespace rather than color, so it is + // easy to spot without being highlighted. + lines.push(''); + lines.push(` ${label('Token: ')}${opts.token}`); + lines.push(''); } - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); - lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); lines.push(''); return lines.join('\n'); } -function displayOrigin(origin: string): string { - return origin.endsWith('/') ? origin : `${origin}/`; -} - const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerBackground, startServerForeground, openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, stdout: process.stdout, stderr: process.stderr, }; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index b0550c65b..09acad29e 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -5,12 +5,20 @@ * `run`, `web`, and `status` all use. */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import type { ServerLogLevel } from '@moonshot-ai/server'; -export const DEFAULT_SERVER_HOST = '127.0.0.1'; +export const LOCAL_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_LAN_HOST = '0.0.0.0'; +export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST; export const DEFAULT_SERVER_PORT = 58627; export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); +/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */ +export const SERVER_TOKEN_FILE = 'server.token'; + export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; @@ -36,6 +44,14 @@ export interface ParsedServerOptions { port: number; logLevel: ServerLogLevel; debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; + /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ + allowRemoteShutdown: boolean; + /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ + allowRemoteTerminals: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; /** Internal: run as an idle-exiting background daemon instead of foreground. */ daemon: boolean; /** Internal: idle-shutdown grace in ms (daemon mode only). */ @@ -43,10 +59,18 @@ export interface ParsedServerOptions { } export interface ServerCliOptions { - host?: string; + host?: string | boolean; port?: string; logLevel?: string; debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; + /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ + allowRemoteShutdown?: boolean; + /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ + allowRemoteTerminals?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; /** Internal flag set by the daemon spawner (`kimi web`). */ daemon?: boolean; /** Internal flag set by the daemon spawner / tests. */ @@ -55,15 +79,33 @@ export interface ServerCliOptions { export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { return { - host: opts.host ?? DEFAULT_SERVER_HOST, + host: parseHost(opts.host), port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, + insecureNoTls: opts.insecureNoTls !== false, + allowRemoteShutdown: opts.allowRemoteShutdown === true, + allowRemoteTerminals: opts.allowRemoteTerminals === true, + allowedHosts: parseAllowedHostArgs(opts.allowedHost), daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; } +export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] { + if (raw === undefined) return []; + return raw + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseHost(raw: string | boolean | undefined): string { + if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST; + if (raw === true || raw === '') return DEFAULT_LAN_HOST; + return raw; +} + function parseIdleGraceMs(raw: string | undefined): number { if (raw === undefined) return DEFAULT_IDLE_GRACE_MS; const n = Number.parseInt(raw, 10); @@ -174,3 +216,41 @@ export async function ensureServerWebReady(origin: string): Promise { clearTimeout(timeout); } } + +/** + * Read the persistent bearer token for the server. + * + * The server writes `/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer `. `homeDir` + * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer ` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index fe893a50a..1ad8aefa3 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -430,8 +430,6 @@ function trackUpdatePrompted( rolloutTelemetry: RolloutTelemetry, ): void { trackUpdateEvent(track, 'update_prompted', { - current: currentVersion, - latest: target.version, current_version: currentVersion, target_version: target.version, source, diff --git a/apps/kimi-code/src/feedback/archive.ts b/apps/kimi-code/src/feedback/archive.ts new file mode 100644 index 000000000..439f68d1b --- /dev/null +++ b/apps/kimi-code/src/feedback/archive.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getCacheDir } from '../utils/paths'; + +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. + +/** + * A file produced for a feedback attachment upload. Both the session log + * archive and the codebase archive share this shape; the generic uploader + * consumes it without caring how the file was produced. + */ +export interface FeedbackArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; +} + +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +} + +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); +} diff --git a/apps/kimi-code/src/feedback/codebase/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts new file mode 100644 index 000000000..1df5976e9 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/filter.ts @@ -0,0 +1,92 @@ +export const DEFAULT_MAX_FILES = 50000; +export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; +// Upper bound for the compressed codebase archive, aligned with the backend's +// per-upload limit. The scanner uses cumulative raw file size as a conservative +// estimate so the resulting zip stays within this bound. +export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024; + +const IGNORED_DIR_NAMES: ReadonlySet = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.turbo', + '.cache', + '.parcel-cache', + 'coverage', + '.nyc_output', + 'target', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.venv', + 'venv', + 'env', + '.idea', +]); + +const SENSITIVE_DIR_NAMES: ReadonlySet = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.kube', + '.docker', +]); + +const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'credentials.json', + 'service-account.json', + 'serviceAccount.json', + '.netrc', + '.htpasswd', + '.pypirc', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', +]); + +const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ + '.pem', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', +]; + +const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet = new Set(['.example', '.sample', '.template']); + +export function isIgnoredDirName(name: string): boolean { + return IGNORED_DIR_NAMES.has(name); +} + +export function isSensitivePath(relativePath: string): boolean { + const segments = relativePath.split('/'); + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true; + } + + const base = segments.at(-1); + if (base === undefined || base.length === 0) return false; + if (SENSITIVE_FILE_NAMES.has(base)) return true; + if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + + if (base.startsWith('.env.')) { + const suffix = base.slice('.env'.length); + return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix); + } + + return false; +} diff --git a/apps/kimi-code/src/feedback/codebase/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts new file mode 100644 index 000000000..7ef51870d --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/index.ts @@ -0,0 +1,3 @@ +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts new file mode 100644 index 000000000..cd4bf2c66 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/packager.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Pack the scanned codebase into a zip, with files placed at the zip root. + */ +export async function packageCodebase( + scan: FeedbackCodebaseScanResult, + archivePath: string, +): Promise { + const entries: PackageEntry[] = scan.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +async function packageEntries( + entries: readonly PackageEntry[], + archivePath: string, +): Promise { + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback archive.'); + } + await mkdir(dirname(archivePath), { recursive: true }); + + const zip = new ZipFile(); + const hash = createHash('sha256'); + const output = createWriteStream(archivePath); + + try { + const done = new Promise((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} diff --git a/apps/kimi-code/src/feedback/codebase/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts new file mode 100644 index 000000000..6df420217 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 000000000..a611d93a3 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts new file mode 100644 index 000000000..c372e4d11 --- /dev/null +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise, +): Promise { + const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename); + try { + const archive = await produce(archivePath); + await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function archiveFromExportedSession(zipPath: string): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/kimi-code/src/feedback/upload.ts b/apps/kimi-code/src/feedback/upload.ts new file mode 100644 index 000000000..629fc6a6f --- /dev/null +++ b/apps/kimi-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadArchiveOptions, +): Promise { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: part.method, + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts index 576c8079e..d66547695 100644 --- a/apps/kimi-code/src/native/native-assets.ts +++ b/apps/kimi-code/src/native/native-assets.ts @@ -12,6 +12,7 @@ import { import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { join as joinPosix } from 'pathe'; import { KIMI_BUILD_INFO } from '#/cli/build-info'; import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; @@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR'); if (cacheDirEnv !== null) return cacheDirEnv; - if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code'); + if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code'); if (platform === 'win32') { const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); return localAppData !== null @@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { : pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache'); } - return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code'); + return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code'); } export function getNativeAssetCacheRoot( diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 8064c089b..5f811e393 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { } host.track('login', { provider: DEFAULT_OAUTH_PROVIDER_NAME, + method: 'oauth', already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 02226507e..69fb2b3f1 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -138,7 +138,14 @@ export interface SlashCommandHost { showSessionPicker(): Promise; sendNormalUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void; readonly skillCommandMap: Map; + readonly pluginCommandMap: Map; // Controller refs readonly streamingUI: StreamingUIController; @@ -164,6 +171,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi const intent = resolveSlashCommandInput({ input, skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, }); @@ -195,6 +203,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.sendSkillActivation(session, intent.skillName, intent.args); return; } + case 'plugin-command': { + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + const session = host.session; + if (session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); + host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); + return; + } case 'message': // Unknown slash command: let /dance claim it before it falls through to // the model as a normal message. This runs *after* builtin and skill diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 57d4e5866..784ef7e61 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,6 +3,7 @@ export * from './parse'; export * from './registry'; export * from './resolve'; export * from './skills'; +export * from './plugin-commands'; export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 73cc99824..d54f9fb2a 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -12,14 +12,17 @@ import { FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '#/utils/open-url'; -import { promptFeedbackInput } from './prompts'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -39,30 +42,46 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, }); - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); + if (res.kind !== 'ok') { + spinner.stop({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); return; } - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + + spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } } // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/src/tui/commands/parse.ts b/apps/kimi-code/src/tui/commands/parse.ts index ee19d3585..1fcc01548 100644 --- a/apps/kimi-code/src/tui/commands/parse.ts +++ b/apps/kimi-code/src/tui/commands/parse.ts @@ -7,6 +7,8 @@ export function parseSlashInput(input: string): ParsedSlashInput | null { const spaceIdx = trimmed.indexOf(' '); const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); - if (name.includes('/')) return null; + // Reject file paths (e.g. `/usr/local/bin`), but allow namespaced plugin + // commands whose name itself contains `/` (e.g. `plugin:frontend/component`). + if (name.includes('/') && !name.includes(':')) return null; return { name, args }; } diff --git a/apps/kimi-code/src/tui/commands/plugin-commands.ts b/apps/kimi-code/src/tui/commands/plugin-commands.ts new file mode 100644 index 000000000..a26e6fdb5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/plugin-commands.ts @@ -0,0 +1,27 @@ +import type { PluginCommandDef } from '@moonshot-ai/kimi-code-sdk'; + +import type { KimiSlashCommand } from './types'; + +export interface PluginSlashCommands { + readonly commands: readonly KimiSlashCommand[]; + /** Maps a namespaced command name (`plugin:command`) to its markdown body. */ + readonly commandMap: ReadonlyMap; +} + +export function pluginCommandName(pluginId: string, name: string): string { + return `${pluginId}:${name}`; +} + +export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands { + const commandMap = new Map(); + const commands = defs.map((def) => { + const commandName = pluginCommandName(def.pluginId, def.name); + commandMap.set(commandName, def.body); + return { + name: commandName, + aliases: [], + description: def.description, + } satisfies KimiSlashCommand; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index f7fcaa0b8..ff90e4914 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -4,9 +4,11 @@ import { isAbsolute, join, resolve } from 'node:path'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, type PluginsPanelSelection, @@ -18,7 +20,7 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel } from '../utils/plugin-source-label'; +import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import type { SlashCommandHost } from './dispatch'; @@ -63,6 +65,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -167,22 +173,28 @@ async function showPluginsPicker( // Each branch of the handler either mounts the next view or restores the // editor itself, so do not pre-restore here — that would flash the editor // for in-place actions like toggling a plugin. - void handlePluginsPanelSelection(host, selection).catch((error: unknown) => { + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { host.showError(`/plugins failed: ${formatErrorMessage(error)}`); }); }, onCancel: () => { host.restoreEditor(); }, - // The Official/Third-party tabs fetch their catalog lazily so /plugins - // opens instantly and Installed/Custom keep working even when the - // marketplace is unreachable. + // Every tab except Custom needs the catalog: Official/Third-party list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). onRequestMarketplace: () => { void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); }, }); host.mountEditorReplacement(panel); - if (options?.initialTab === 'official' || options?.initialTab === 'third-party') { + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Third-party list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { panel.setMarketplaceLoading(); void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); } @@ -260,6 +272,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise { + // Kimi-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace...`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + async function applyPluginEnabled( host: SlashCommandHost, id: string, @@ -287,6 +360,7 @@ async function applyPluginEnabled( async function handlePluginsPanelSelection( host: SlashCommandHost, + panel: PluginsPanelComponent, selection: PluginsPanelSelection, ): Promise { switch (selection.kind) { @@ -319,18 +393,23 @@ async function handlePluginsPanelSelection( await reloadPlugins(host); await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'install': { - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' }); - // Close the panel after installing so the success notice and the - // "/reload or /new" / post-install tip are visible in the transcript. - host.restoreEditor(); + case 'install': + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); return; - } case 'install-source': - host.showStatus(`Installing plugin from ${truncateForStatus(selection.source)}…`); - await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' }); - host.restoreEditor(); + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); return; } } @@ -362,7 +441,8 @@ async function handlePluginMcpSelection( async function removePlugin(host: SlashCommandHost, id: string): Promise { await host.requireSession().removePlugin(id); - host.showStatus(`Removed ${id}. Run /reload or /new to apply plugin changes.`); + host.showStatus(`Removed ${id}.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -394,25 +474,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const session = host.requireSession(); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), ); - showPluginInstallResult(host, beforeList, summary, options); + showPluginInstallResult(host, beforeList, summary); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], summary: PluginSummary, - options?: { - readonly successNotice?: 'marketplace'; - }, ): void { const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; @@ -421,15 +497,8 @@ function showPluginInstallResult( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const action = describeInstallAction(previous, summary); - host.showStatus( - `${action} (${summary.id}).${mcpHint} Run /new or /reload to apply plugin changes.`, - ); - if (options?.successNotice === 'marketplace') { - host.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new or /reload to apply plugin changes.`, - ); - } + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } function describeInstallAction( diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 67fd89a29..0bdbb8899 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -57,16 +57,58 @@ export function promptLogoutProviderSelection( }); } -export function promptFeedbackInput(host: SlashCommandHost): Promise { +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise { return new Promise((resolve) => { const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { host.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); }); host.mountEditorReplacement(dialog); }); } +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + export function promptApiKey( host: SlashCommandHost, platformName: string, diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a8700d95e..f4dca0489 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const session = host.session; if (session !== undefined) { - await session.reloadSession(); + await session.reloadSession({ forcePluginSessionStartReminder: true }); await host.reloadCurrentSessionView(session, 'Session reloaded.'); } diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index a47f11409..e67457a94 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -26,6 +26,12 @@ export type SlashCommandIntent = readonly skillName: string; readonly args: string; } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } | { readonly kind: 'message'; readonly input: string } | { readonly kind: 'blocked'; @@ -41,6 +47,7 @@ export type SlashCommandIntent = export interface ResolveSlashCommandInput { readonly input: string; readonly skillCommandMap: ReadonlyMap; + readonly pluginCommandMap: ReadonlyMap; readonly isStreaming: boolean; readonly isCompacting: boolean; } @@ -92,6 +99,26 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla }; } + if (options.pluginCommandMap.has(parsed.name)) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + const separator = parsed.name.indexOf(':'); + const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator); + const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1); + return { + kind: 'plugin-command', + commandName, + pluginId, + args: parsed.args.trim(), + }; + } + return { kind: 'message', input: options.input, diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 5a1c963d7..01a858f07 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -15,6 +15,7 @@ import { BackgroundAgentStatusComponent } from '../components/messages/backgroun import { CronMessageComponent } from '../components/messages/cron-message'; import { ReadGroupComponent } from '../components/messages/read-group'; import { SkillActivationComponent } from '../components/messages/skill-activation'; +import { PluginCommandComponent } from '../components/messages/plugin-command'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { UserMessageComponent } from '../components/messages/user-message'; @@ -237,6 +238,9 @@ function isContextUndoAnchor(message: ContextMessage): boolean { if (origin.kind === 'skill_activation') { return origin.trigger === 'user-slash'; } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } return false; } @@ -295,6 +299,9 @@ function formatUndoChoiceLabel( if (name.length === 0) return 'Skill: unknown'; return args.length > 0 ? `/${name} ${args}` : `/${name}`; } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? 'User message'; + } const content = singleLine(entry.content); const imageCount = entry.imageAttachmentIds?.length ?? 0; @@ -314,9 +321,19 @@ function formatUndoChoiceInput(entry: TranscriptEntry): string { if (name.length === 0) return ''; return args.length > 0 ? `/${name} ${args}` : `/${name}`; } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? entry.content; + } return entry.content; } +function formatPluginCommandSlash(data: NonNullable): string | undefined { + const name = `${data.pluginId}:${data.commandName}`; + const args = singleLine(data.args ?? ''); + if (name.length === 0) return undefined; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; +} + function singleLine(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } @@ -374,7 +391,8 @@ function undoLimitFromError( function isUndoAnchorEntry(entry: TranscriptEntry): boolean { return ( entry.kind === 'user' || - (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') + (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') || + entry.kind === 'plugin_command' ); } @@ -400,6 +418,7 @@ function isUndoContextEntry(entry: TranscriptEntry): boolean { case 'tool_call': case 'thinking': case 'skill_activation': + case 'plugin_command': case 'cron': return true; case 'status': @@ -440,7 +459,8 @@ function removeUndoContextComponents( function isUndoAnchorComponent(child: Component): boolean { return ( child instanceof UserMessageComponent || - (child instanceof SkillActivationComponent && child.trigger === 'user-slash') + (child instanceof SkillActivationComponent && child.trigger === 'user-slash') || + child instanceof PluginCommandComponent ); } @@ -459,6 +479,7 @@ function isUndoContextComponent(child: Component): boolean { child instanceof AgentSwarmProgressComponent || child instanceof ReadGroupComponent || child instanceof SkillActivationComponent || + child instanceof PluginCommandComponent || child instanceof BackgroundAgentStatusComponent || child instanceof CronMessageComponent ); diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 215b0fc6c..366860016 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,5 +1,7 @@ import { ensureDaemon } from '#/cli/sub/server/daemon'; +import { tryResolveServerToken } from '#/cli/sub/server/shared'; import { openUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; @@ -63,13 +65,28 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { return; } - const url = webSessionUrl(origin, sessionId); + // Resolve the persistent token so the opened browser auto-authenticates via + // the `#token=` fragment — matching the `kimi web` subcommand. Show the URL + // and token in green under the status line so they can be copied before the + // terminal exits. Best-effort: an older/never-started server has no token + // file, so we fall back to the plain URL and skip the token line. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + host.showStatus(`open ${url}`, 'success'); + if (token !== undefined) { + host.showStatus(`Token: ${token}`, 'success'); + } openUrl(url); host.setExitOpenUrl(url); await host.stop(); } -/** Build the deep-link URL the web UI recognises for a session. */ -export function webSessionUrl(origin: string, sessionId: string): string { - return `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; +/** + * Build the deep-link URL the web UI recognises for a session. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `kimi web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; } diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 39ed97c49..be90abf00 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -10,8 +10,20 @@ */ import { Container } from '@earendil-works/pi-tui'; +import type { Component } from '@earendil-works/pi-tui'; + +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -19,15 +31,56 @@ export class GutterContainer extends Container { super(); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const inner = Math.max(1, width - this.leftPad - this.rightPad); const lead = ' '.repeat(this.leftPad); - const out: string[] = []; + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; for (const child of this.children) { - for (const line of child.render(inner)) { - out.push(lead + line); + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + prefixed.push(lines.map((line) => lead + line)); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); } } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + return out; } } diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 9b03bff70..93ab6e7e7 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -20,6 +20,12 @@ export class MoonLoader extends Text { private colorFn?: (s: string) => string; private label: string; private displayText = ''; + // Inline text used when the spinner is embedded into another line (e.g. the + // agent-swarm progress status line). It intentionally excludes the tip: the + // tip is only rendered when the loader sits on its own row in the activity + // pane, otherwise it would get squeezed against whatever follows the inline + // spinner (like the swarm progress bar). + private inlineText = ''; private tip: string = ''; private availableWidth = 0; @@ -75,13 +81,14 @@ export class MoonLoader extends Text { } renderInline(): string { - return this.displayText; + return this.inlineText; } private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.inlineText = baseText; let text = baseText; if (this.tip) { const withTip = baseText + currentTheme.fg('textDim', this.tip); 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 02bba03f2..733d5b8f8 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -28,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record; } /** @@ -49,7 +50,11 @@ export interface VisibleTodos { */ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { if (todos.length <= MAX_VISIBLE) { - return { rows: [...todos], hidden: 0 }; + return { + rows: [...todos], + hidden: 0, + hiddenCounts: { done: 0, in_progress: 0, pending: 0 }, + }; } const inProgress: number[] = []; @@ -91,9 +96,18 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { } const sortedIdx = [...picked].toSorted((a, b) => a - b); + + const hiddenCounts: Record = { done: 0, in_progress: 0, pending: 0 }; + for (const [i, todo] of todos.entries()) { + if (!picked.has(i)) { + hiddenCounts[todo.status] += 1; + } + } + return { rows: sortedIdx.map((i) => todos[i] as TodoItem), hidden: todos.length - sortedIdx.length, + hiddenCounts, }; } @@ -151,12 +165,16 @@ export class TodoPanelComponent implements Component { ); } } else { - const { rows, hidden } = selectVisibleTodos(this.todos); + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); for (const todo of rows) { lines.push(renderRow(todo, c)); } if (hidden > 0) { - lines.push(chalk.hex(c.textDim)(` … +${hidden} more · ctrl+t to expand`)); + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); } } @@ -191,3 +209,15 @@ function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): st return chalk.hex(colors.text)(title); } } + +const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [ + { status: 'done', label: 'done' }, + { status: 'in_progress', label: 'in progress' }, + { status: 'pending', label: 'pending' }, +]; + +export function formatHiddenCounts(counts: Record): string { + return STATUS_LABELS.filter(({ status }) => counts[status] > 0) + .map(({ status, label }) => `${counts[status]} ${label}`) + .join(' · '); +} 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 24098cd59..c85339d90 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -17,7 +17,7 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { currentTheme } from '#/tui/theme'; +import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -30,6 +30,9 @@ export interface ChoiceOption { readonly tone?: 'danger'; /** Optional explanatory text shown below the label. */ readonly description?: string | undefined; + /** Color token applied to the description while this option is selected, drawing + * attention to important details. Falls back to `textMuted` when unset or not selected. */ + readonly descriptionTone?: ColorToken; } export interface ChoicePickerOptions { @@ -37,6 +40,8 @@ export interface ChoicePickerOptions { readonly hint?: string; readonly formatHint?: (text: string) => string; readonly notice?: string; + /** Color tone for the notice line. Defaults to 'success'. */ + readonly noticeTone?: 'success' | 'warning'; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ @@ -142,8 +147,12 @@ export class ChoicePickerComponent extends Container implements Focusable { ); } if (this.opts.notice !== undefined) { + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); for (const noticeLine of this.opts.notice.split(/\r?\n/)) { - lines.push(currentTheme.fg('success', ` ${noticeLine}`)); + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } } } lines.push(''); @@ -168,8 +177,10 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(currentTheme.fg('textMuted', ` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } 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 3fefe86c0..38ac3bd9e 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 @@ -5,6 +5,10 @@ * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with * the OAuth login flow. The box embeds a `pi-tui` Input for the actual * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). */ import { @@ -83,7 +87,15 @@ export class FeedbackInputDialogComponent extends Container implements Focusable const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const inputLine = this.input.render(innerWidth)[0] ?? '> '; - const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + inputLine, + '', + footerLine, + ]; if (safeWidth < 4) { return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; 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 4a51e4979..fcc09941d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -24,6 +24,8 @@ const MCP_SERVER_PREFIX = 'mcp:'; const REMOVE_CONFIRM_CANCEL = 'cancel'; const REMOVE_CONFIRM_REMOVE = 'remove'; +const INSTALL_TRUST_EXIT = 'exit'; +const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; interface PluginsOverviewItem { @@ -193,6 +195,55 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { } } +export type PluginInstallTrustConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginInstallTrustConfirmOptions { + /** Plugin display name or source, shown in the title for identification. */ + readonly label: string; + readonly onDone: (result: PluginInstallTrustConfirmResult) => void; +} + +/** + * Confirmation shown before installing a third-party (unofficial) plugin. + * Defaults to "Exit" so the user must explicitly switch to "Trust and install" + * to proceed with a plugin that Kimi has not reviewed. + */ +export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginInstallTrustConfirmOptions) { + super({ + title: `Install third-party plugin ${opts.label}?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + notice: + '⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' + + 'skills, or files that run code and access your workspace. Install it only if you ' + + 'trust the source.', + noticeTone: 'warning', + options: [ + { + value: INSTALL_TRUST_EXIT, + label: 'Exit', + description: 'Cancel the installation.', + }, + { + value: INSTALL_TRUST_TRUST, + label: 'Trust and install', + tone: 'danger', + description: 'Install this third-party plugin anyway.', + }, + ], + onSelect: (value) => { + opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } +} + function overviewPluginDescription(plugin: PluginSummary): string { const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`; const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`; @@ -289,6 +340,7 @@ export class PluginsPanelComponent extends Container implements Focusable { private activeTabIndex: number; private selectedIndex = 0; private market: MarketState = { status: 'idle' }; + private installing: string | undefined; constructor(opts: PluginsPanelOptions) { super(); @@ -323,6 +375,16 @@ export class PluginsPanelComponent extends Container implements Focusable { this.market = { status: 'error', message }; } + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { return PLUGINS_PANEL_TABS[this.activeTabIndex]!; } @@ -351,7 +413,9 @@ export class PluginsPanelComponent extends Container implements Focusable { } private requestMarketplaceIfNeeded(): void { - if (this.market.status === 'idle' && this.activeTab.id !== 'installed' && this.activeTab.id !== 'custom') { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { this.market = { status: 'loading' }; this.opts.onRequestMarketplace?.(); } @@ -423,6 +487,16 @@ export class PluginsPanelComponent extends Container implements Focusable { return; } if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); } } @@ -452,11 +526,14 @@ export class PluginsPanelComponent extends Container implements Focusable { } override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } const colors = currentTheme.palette; const tab = this.activeTab.id; const hint = tab === 'installed' - ? ' Tab switch · Space toggle · D remove · M MCP · Enter details · R reload · Esc cancel' + ? this.installedHint() : tab === 'custom' ? ' Tab switch · Enter install · Esc cancel' : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; @@ -497,6 +574,23 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(mutedHintLine(` ${installed.length} installed`, colors)); } + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find((e) => e.id === plugin.id); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; + } + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { const colors = currentTheme.palette; const selected = index === this.selectedIndex; @@ -504,10 +598,15 @@ export class PluginsPanelComponent extends Container implements Focusable { const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); + const update = this.installedUpdateStatus(plugin); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); } + if (update !== undefined) { + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); + } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); } @@ -580,6 +679,19 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(''); lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { 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 a1718a5eb..fdcd81aac 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 @@ -125,11 +125,20 @@ export class TaskOutputViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\u0002' /* C-b */) { + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '\u0006' /* C-f */) { + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -240,7 +249,7 @@ export class TaskOutputViewer extends Container implements Focusable { ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; 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 600817547..05bd81b15 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -8,6 +8,7 @@ import { matchesKey, Key, SelectList, + visibleWidth, type SelectItem, type TUI, } from '@earendil-works/pi-tui'; @@ -15,6 +16,8 @@ import { import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; + import { extractAtPrefix } from './file-mention-provider'; import { WrappingSelectList } from './wrapping-select-list'; @@ -46,6 +49,11 @@ interface AutocompleteListFactoryInternals { createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList; } +interface AutocompleteTriggerInternals { + tryTriggerAutocomplete: (explicitTab?: boolean) => void; + requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; +} + // 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 = { @@ -114,6 +122,11 @@ function getNewlineInput(data: string): string | undefined { export class CustomEditor extends Editor { public onEscape?: () => void; + /** + * Fired for every input that is not a lone Escape. Used to disarm a pending + * double-Esc so only two consecutive Escape presses trigger the shortcut. + */ + public onNonEscapeInput?: () => void; public onCtrlD?: () => void; public onCtrlC?: () => void; public onToggleToolExpand?: () => void; @@ -134,6 +147,10 @@ export class CustomEditor extends Editor { public onUpArrowEmpty?: () => boolean; public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; public connectedAbove = false; public borderHighlighted = false; /** @@ -181,6 +198,17 @@ export class CustomEditor extends Editor { } return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); }; + + // pi-tui auto-triggers autocomplete for `/` (and letters in a slash + // context) with force:false, which routes through the slash-command + // branch. In bash mode `/` is a path separator, not a command prefix, so + // shadow the trigger to request file path completion (force:true) instead. + // Prompt mode keeps the original force:false behaviour. `tryTriggerAutocomplete` + // is private in pi-tui's typings but a plain prototype method at runtime. + const triggerInternals = this as unknown as AutocompleteTriggerInternals; + triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { + triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); + }; } private expandPasteMarkerAtCursor(): boolean { @@ -226,8 +254,9 @@ export class CustomEditor extends Editor { const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; + const isBash = this.inputMode === 'bash'; const text = this.getText().trimStart(); - if (text.startsWith('/')) { + if (text.startsWith('/') && !isBash) { // Paint only the FIRST editor content line; multi-line slash commands // are not a thing in practice. const original = lines[firstContentIdx]; @@ -247,7 +276,11 @@ export class CustomEditor extends Editor { } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const withPrompt = injectPromptSymbol(firstContent); + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); if (withPrompt !== undefined) { lines[firstContentIdx] = withPrompt; } @@ -258,10 +291,13 @@ export class CustomEditor extends Editor { // side bars through the same hook to stay in sync. return wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, }); } private computeArgumentHint(): string | undefined { + // Argument hints describe slash commands, which do not exist in bash mode. + if (this.inputMode === 'bash') return undefined; const text = this.getText(); const match = /^\/(\S+)( ?)$/.exec(text); if (match === null) return undefined; @@ -283,6 +319,12 @@ export class CustomEditor extends Editor { return; } + // Any input other than a lone Escape breaks a pending double-Esc sequence, + // so the shortcut only fires for two consecutive Escape presses. + if (!matchesKey(normalized, Key.escape)) { + this.onNonEscapeInput?.(); + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { @@ -375,6 +417,19 @@ export class CustomEditor extends Editor { this.onUndo?.(); } + // Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt + // mode. Because the `!` is not in the buffer, "deleting" it is really + // "delete on empty bash input". + if ( + this.inputMode === 'bash' && + this.getText().length === 0 && + (matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace)) + ) { + this.inputMode = 'prompt'; + this.onInputModeChange?.('prompt'); + return; + } + const newlineInput = getNewlineInput(normalized); if (newlineInput !== undefined) { this.onInsertNewline?.(); @@ -411,7 +466,32 @@ export class CustomEditor extends Editor { return; } + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + return; + } + + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + this.reopenAutocompleteAfterInput(); } @@ -434,10 +514,24 @@ export class CustomEditor extends Editor { // Reopen path / argument completion right after a `/` is typed // (e.g. `/add-dir /` or an `@dir/` mention). if (textBeforeCursor.endsWith('/')) { - const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); const isAtMention = extractAtPrefix(textBeforeCursor) !== null; - if (isSlashArgument || isAtMention) { + if (isAtMention) { trigger(); + } else if (this.inputMode === 'bash') { + // In bash mode `/` is a path separator, not a slash command. A bare + // leading `/` is already handled by the tryTriggerAutocomplete shadow + // in the constructor; this branch covers the inline case (e.g. `ls /`, + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true + // is required so pi-tui's own slash-command handling is bypassed — + // force:false would let it pop up subcommand completions. + if (textBeforeCursor.trimStart() !== '/') { + editor.requestAutocomplete?.({ force: true, explicitTab: false }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } } return; } @@ -445,7 +539,10 @@ export class CustomEditor extends Editor { // After accepting a slash command name via Tab, pi-tui inserts a trailing // space and closes the menu without triggering argument completion. Reopen // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + // Skipped in bash mode: `/` is a path there, and force:false would let + // pi-tui's own slash-command handling pop up subcommand completions. if ( + this.inputMode !== 'bash' && textBeforeCursor.endsWith(' ') && textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' ') @@ -593,12 +690,17 @@ function truncateHint(hint: string, maxLen: number): string { * default foreground colour renders the symbol. Returns `undefined` if the * line is too short or doesn't begin with the expected padding. */ -export function injectPromptSymbol(line: string): string | undefined { +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { if (line.length < 4) return undefined; for (let i = 0; i < 4; i++) { if (line[i] !== ' ') return undefined; } - return ' > ' + line.slice(4); + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); } /** @@ -612,21 +714,37 @@ export function injectPromptSymbol(line: string): string | undefined { * inner SGR intact; only column 0 and the last column are overlaid, and * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. */ export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, - options: { readonly connectedAbove?: boolean } = {}, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { + const isTop = !seenTop; const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } return paint(leftCorner + middle + rightCorner); } if (line.length === 0) return line; 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 fb6ae3acb..cb9029bb7 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 @@ -44,6 +44,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly workDir: string, private readonly fdPath: string | null, additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', ) { this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that @@ -67,20 +68,11 @@ export class FileMentionProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); - if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { - return null; - } - - if ( - shouldSuppressSlashArgumentCompletion( - textBeforeCursor, - currentLine.slice(cursorCol), - options.force, - ) - ) { - return null; - } - + // `@` file / folder mentions take priority over the slash-command guards + // below. Without this, typing `@` inside a slash command's argument text + // (e.g. `/goal Fix the @|checkout docs`) would be swallowed by + // `shouldSuppressSlashArgumentCompletion` before the mention branch ever + // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { if (this.fdPath === null || this.additionalDirs.length > 0) { @@ -104,6 +96,20 @@ export class FileMentionProvider implements AutocompleteProvider { } } + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { + return null; + } + + if ( + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; + } + // Handle slash-command name completion ourselves so that aliases are // searchable and visible in the label. if (!options.force && textBeforeCursor.startsWith('/')) { @@ -164,13 +170,26 @@ export class FileMentionProvider implements AutocompleteProvider { } } - const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); - if (slashArgumentSuggestions !== null) { - return slashArgumentSuggestions; + // In bash mode `/` is a path separator, not a slash command. Skip slash + // command argument handling so an absolute path that happens to start with + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. + if (this.getInputMode() !== 'bash') { + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } } try { - return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (inner === null || this.getInputMode() !== 'bash') { + return inner; + } + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. + return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; } catch { return null; } @@ -183,6 +202,15 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { + // In bash mode a leading `/` is a path, but pi-tui's applyCompletion + // mistakes it for a slash command (prefix starts with `/`, nothing before + // it, no second `/`) and prepends another `/`, producing e.g. + // `//Applications/ ` with a trailing space that also blocks further + // completion. Handle path completion ourselves so the value replaces the + // prefix verbatim. `@` mentions keep pi-tui's behaviour. + if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { + return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); + } return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } @@ -199,6 +227,48 @@ export function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +/** + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. + */ +function isDotPrefixedEntry(item: AutocompleteItem): boolean { + const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; + return name.startsWith('.'); +} + +/** + * Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path + * branch (no trailing space, so a completed directory can be extended with the + * next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which + * would prepend an extra `/` to a bare leading `/` path. For a quoted + * directory value (path contains spaces), the cursor stays inside the closing + * quote so follow-up `/` completion keeps working. + */ +function applyPathCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + const isDirectory = item.label.endsWith('/'); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = + isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; +} + function getFsMentionSuggestions( workDir: string, additionalDirs: readonly string[], 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 7b002ef66..721711801 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -11,40 +11,82 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +type AssistantMarkdownOptions = { + transient?: boolean; +}; export class AssistantMessageComponent implements Component { private contentContainer: Container; + private markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; + private renderCache: { width: number; lines: string[] } | undefined; + constructor(showBullet: boolean = true) { this.showBullet = showBullet; this.contentContainer = new Container(); } - setShowBullet(show: boolean): void { - this.showBullet = show; + private markRenderDirty(): void { + this.renderCache = undefined; } - updateContent(text: string): void { - const displayText = text; - if (displayText === this.lastText) return; + setShowBullet(show: boolean): void { + if (this.showBullet === show) return; + this.showBullet = show; + this.markRenderDirty(); + } + + updateContent(text: string, opts?: AssistantMarkdownOptions): void { + const displayText = text.trim(); + const transient = opts?.transient === true; + + if (displayText === this.lastText && transient === this.lastTransient) return; + this.lastText = displayText; - this.contentContainer.clear(); - if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createMarkdownTheme())); + this.lastTransient = transient; + this.markRenderDirty(); + + if (displayText.length === 0) { + this.contentContainer.clear(); + this.markdown = undefined; + this.markdownTransient = false; + return; } + + if (this.markdown === undefined || this.markdownTransient !== transient) { + this.contentContainer.clear(); + this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient })); + this.markdownTransient = transient; + this.contentContainer.addChild(this.markdown); + return; + } + + this.markdown.setText(displayText); } invalidate(): void { // 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. + // the Markdown child with the new theme while preserving transient mode. + this.markRenderDirty(); this.contentContainer.clear(); + this.markdown = undefined; + if (this.lastText.trim().length > 0) { - this.contentContainer.addChild( - new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()), + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); } } @@ -54,6 +96,14 @@ export class AssistantMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); const contentLines = this.contentContainer.render(contentWidth); @@ -64,6 +114,10 @@ export class AssistantMessageComponent implements Component { i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts new file mode 100644 index 000000000..230cf3e83 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -0,0 +1,58 @@ +/** + * Plugin command invocation card. + * + * When the user runs `/plugin:command args`, the TUI renders a compact card + * instead of expanding the command body into the user bubble: + * + * ▶ /plugin:command + * args + * + * The args line is optional. Core expands the command body into the LLM + * context; the TUI only consumes the `plugin_command.activated` event. + */ + +import { Container, Text, Spacer } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const ARGS_PREVIEW_MAX = 200; + +export class PluginCommandComponent extends Container { + private headText: Text; + private previewText?: Text; + private readonly label: string; + private readonly args?: string; + + constructor(pluginId: string, commandName: string, args?: string) { + super(); + this.label = `/${pluginId}:${commandName}`; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + 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.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + 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/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 000000000..3c5fc20da --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,134 @@ +import { Container, Text } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const RUNNING_TAIL_LINES = 5; +const TIMER_INTERVAL_MS = 1000; +// Cap the live running buffer so a command that spews output for minutes can't +// grow memory without bound or make every render re-strip a multi-MB string. +// Only affects the transient running tail; the final view uses the full +// captured stdout/stderr passed to finish(). +const MAX_COMBINED_CHARS = 256 * 1024; +const KEEP_COMBINED_CHARS = 64 * 1024; + +/** + * Live view for a user-initiated `!` shell command. Two phases: + * + * - running: dim, ANSI-stripped tail of the combined output, a `+N lines` + * overflow marker, an elapsed `(Xs)` timer that ticks every second, and a + * `(ctrl+b to run in background)` hint — matching claude-code's running card + * so warnings are grey rather than red while the command works. + * - finished: the standard `formatBashOutputForDisplay` view (stderr red only + * on failure), the timer stopped and the running chrome removed. + * + * Hardened so a misbehaving command can never crash the TUI: the running + * buffer is capped, and every render/render-request path swallows errors. + */ +export class ShellRunComponent extends Container { + private readonly textComponent: Text; + private combined = ''; + private running = true; + private backgrounded = false; + private disposed = false; + private finalStdout = ''; + private finalStderr = ''; + private finalIsError?: boolean; + private readonly startedAt = Date.now(); + private timer: ReturnType | undefined; + + constructor(private readonly requestRender: () => void) { + super(); + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS); + } + + append(text: string): void { + if (this.disposed || !this.running || text.length === 0) return; + this.combined += text; + if (this.combined.length > MAX_COMBINED_CHARS) { + this.combined = this.combined.slice(-KEEP_COMBINED_CHARS); + } + this.flush(); + } + + finish(stdout: string, stderr: string, isError?: boolean): void { + if (this.disposed || !this.running) return; + this.running = false; + this.finalStdout = stdout; + this.finalStderr = stderr; + this.finalIsError = isError; + this.clearTimer(); + this.flush(); + } + + finishBackgrounded(): void { + if (this.disposed || !this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.disposed = true; + this.clearTimer(); + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + if (this.disposed) return; + try { + this.textComponent.setText(this.renderText()); + this.requestRender(); + } catch { + // Never let a render/render-request error escape into a timer or event + // handler — an uncaught exception there can take down the whole TUI. + } + } + + private clearTimer(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + try { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + if (!this.running) { + return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } catch { + return ' (output unavailable)'; + } + } +} 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 7377a3f52..c23c356ce 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -12,20 +12,35 @@ export class StatusMessageComponent extends Container { super(); 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.textComponent = new Text(this.renderText(), 0, 0); this.addChild(this.textComponent); } + // Update the body in place (used for live-streamed `!` shell output) without + // remounting the component. + updateContent(content: string): void { + this.content = content; + this.textComponent.setText(this.renderText()); + } + override invalidate(): void { - const text = this.color === undefined - ? currentTheme.fg('textDim', this.content) - : currentTheme.fg(this.color, this.content); - this.textComponent.setText(` ${text}`); + this.textComponent.setText(this.renderText()); super.invalidate(); } + + // Indent every line, not just the first. The `content` may be multi-line + // (e.g. `!` shell output); prefixing the whole string once would only indent + // the first line and leave the rest at column 0. Strip carriage returns + // first: a trailing `\r` (e.g. from CRLF server error pages) is zero-width + // for the line wrapper, so the padding spaces appended after it overwrite + // the visible content and the line renders blank. + private renderText(): string { + const colored = + this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + return colored.replaceAll('\r', '').split('\n').map((line) => ` ${line}`).join('\n'); + } } export class NoticeMessageComponent extends Container { diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 000000000..5a6885ac4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,32 @@ +import type { Component } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older steps within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and renders them as a single + * muted line, e.g. `… thinking 5 times, call 50 tools`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0; + } + + addCounts(thinking: number, tool: number): void { + this.thinking += thinking; + this.tool += tool; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 6ff652d4f..ca0847581 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -15,6 +15,7 @@ import { } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; @@ -32,6 +33,8 @@ export class ThinkingComponent implements Component { // once the transcript accumulates many finalized thinking blocks. private readonly textComponent: Text; + private renderCache: { width: number; lines: string[] } | undefined; + constructor( text: string, showMarker: boolean = true, @@ -48,13 +51,19 @@ export class ThinkingComponent implements Component { } } + private markRenderDirty(): void { + this.renderCache = undefined; + } + invalidate(): void { + this.markRenderDirty(); this.textComponent.setText(this.styled(this.text)); } setText(text: string): void { if (this.text === text) return; this.text = text; + this.markRenderDirty(); this.textComponent.setText(this.styled(text)); } @@ -64,6 +73,7 @@ export class ThinkingComponent implements Component { finalize(): void { this.mode = 'finalized'; + this.markRenderDirty(); this.stopSpinner(); } @@ -74,12 +84,22 @@ export class ThinkingComponent implements Component { setExpanded(expanded: boolean): void { if (this.expanded === expanded) return; this.expanded = expanded; + this.markRenderDirty(); } render(width: number): string[] { + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === width + ) { + return this.renderCache.lines; + } + const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + let rendered: string[]; if (this.mode === 'live') { const visibleLines = contentLines.length > THINKING_PREVIEW_LINES @@ -89,39 +109,45 @@ export class ThinkingComponent implements Component { 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - return [ + rendered = [ '', spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; + } else { + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + + if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { + rendered = lines; + } else { + // Leading blank + first PREVIEW_LINES content lines + hint line. + const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES); + const remaining = contentLines.length - THINKING_PREVIEW_LINES; + const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; + const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + truncated.push( + ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), + ); + rendered = truncated; + } } - const rendered: string[] = ['']; - for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; - rendered.push(p + contentLines[i]); + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: rendered }; } - - if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { - return rendered; - } - - // Leading blank + first PREVIEW_LINES content lines + hint line. - const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); - const remaining = contentLines.length - THINKING_PREVIEW_LINES; - const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; - const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); - const hintWidth = Math.max(0, width - indentWidth); - truncated.push( - ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), - ); - return truncated; + return rendered; } private startSpinner(): void { if (this.ui === undefined || this.spinnerInterval !== undefined) return; this.spinnerInterval = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.markRenderDirty(); this.ui?.requestRender(); }, BRAILLE_SPINNER_INTERVAL_MS); } 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 9cb05179d..e1913213d 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -25,6 +25,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; @@ -413,7 +414,7 @@ function extractKeyArgument( }; // Glob: concatenate multiple args into a single summary so the header - // shows pattern, optional explicit path, and include_dirs override. + // shows pattern, optional explicit path, and ignored-file inclusion. if (toolName === 'Glob') { const pattern = args['pattern']; if (typeof pattern !== 'string' || pattern.length === 0) return null; @@ -422,8 +423,8 @@ function extractKeyArgument( if (typeof path === 'string' && path.length > 0) { summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`; } - if (args['include_dirs'] === false) { - summary += ' · no dirs'; + if (args['include_ignored'] === true) { + summary += ' · include ignored'; } return truncateArgValue('pattern', summary); } @@ -463,6 +464,8 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] { } class PrefixedWrappedLine implements Component { + private renderCache: { width: number; lines: string[] } | undefined; + constructor( private readonly firstPrefix: string, private readonly continuationPrefix: string, @@ -473,12 +476,18 @@ class PrefixedWrappedLine implements Component { private readonly tailLines?: number, ) { } - invalidate(): void { } + invalidate(): void { + this.renderCache = undefined; + } render(width: number): string[] { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) { + return this.renderCache.lines; + } + const prefixWidth = Math.max( visibleWidth(this.firstPrefix), visibleWidth(this.continuationPrefix), @@ -489,11 +498,15 @@ class PrefixedWrappedLine implements Component { this.tailLines !== undefined && wrapped.length > this.tailLines ? wrapped.slice(wrapped.length - this.tailLines) : wrapped; - return lines + const rendered = lines .map((line, index) => index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, ) .map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } @@ -620,7 +633,49 @@ export class ToolCallComponent extends Container { this.startDetachHintTimer(); } + private renderCache: + | { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] } + | undefined; + + override render(width: number): string[] { + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childLines: string[][] = []; + let allReused = cacheValid; + + let i = 0; + for (const child of this.children) { + const lines = child.render(width); + childRefs.push(child); + childLines.push(lines); + if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) { + allReused = false; + } + i++; + } + + if (allReused) { + return cache!.lines; + } + + const out: string[] = []; + for (const lines of childLines) { + for (const line of lines) out.push(line); + } + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: out, childRefs, childLines }; + } + return out; + } + override invalidate(): void { + this.renderCache = undefined; this.headerText.setText(this.buildHeader()); this.rebuildBody(); super.invalidate(); 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 43ed13459..0e9b2c32c 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -8,19 +8,29 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { private text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, images?: ImageAttachment[]) { + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(text: string, images?: ImageAttachment[], bullet?: string) { this.text = text; + this.bullet = bullet; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } + private markRenderDirty(): void { + this.renderCache = undefined; + } + invalidate(): void { + this.markRenderDirty(); for (const img of this.imageThumbnails) { img.invalidate?.(); } @@ -30,7 +40,16 @@ export class UserMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET); + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, safeWidth - bulletWidth); @@ -41,7 +60,8 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text — re-dye on every render so theme switches are reflected + // Text is re-dyed from the current theme; invalidate() (theme change) clears + // the render cache so the new colours are picked up on the next render. const coloredText = currentTheme.boldFg('roleUser', this.text); const textLines = new Text(coloredText, 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { @@ -57,7 +77,7 @@ export class UserMessageComponent implements Component { } } - return lines.map((line) => { + const rendered = lines.map((line) => { // Inline image sequences (Kitty / iTerm2) carry their own placement // information and have zero visible width, but pi-tui's truncateToWidth // treats the embedded base64 payload as visible text and would chop the @@ -66,6 +86,10 @@ export class UserMessageComponent implements Component { if (isImageLine(line)) return line; return truncateToWidth(line, safeWidth, '…'); }); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } 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 77800b97e..a3c959a6a 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -22,26 +22,40 @@ export class QueuePaneComponent extends Container { this.messages = options.messages; if (options.messages.length > 0) { + // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when + // there is at least one plain-text item that steering would actually send. + const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); + const canSteer = options.canSteerImmediately && hasSteerable; 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'; + : canSteer + ? ' ↑ to edit · ctrl-s to steer immediately' + : ' ↑ to edit · will send after current task'; } } override render(width: number): string[] { const accent = (text: string) => currentTheme.fg('accent', text); + const shell = (text: string) => currentTheme.fg('shellMode', text); const dim = (text: string) => currentTheme.fg('textDim', text); const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; 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 (item.mode === 'bash') { + // Shell commands get a `$ ` prompt and the shell-mode hue so they read + // as commands, not as plain text that would be sent to the model. + const prompt = '$ '; + const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix) + shell(prompt + truncated)); + } else { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } } if (this.hint !== undefined) { diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts index 922e30fdd..eccb3f3fb 100644 --- a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -1,4 +1,3 @@ // Timing constants for the clipboard-image hint controller. export const FOCUS_DEBOUNCE_MS = 1_000; -export const HINT_COOLDOWN_MS = 30_000; export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 9e8d621f5..d72a62def 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -16,12 +16,15 @@ export { } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = "You're not signed in. Opening GitHub Issues for feedback…"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,6 +34,10 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users // at the `/export-debug-zip` workflow so they can share diagnostics with us. export function errorReportHintLine(): string { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e1..8c8f9807b 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -9,6 +9,10 @@ export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; export function isManagedUsageProvider( providerKey: string | undefined, diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index c04b5a763..f9d0a7f27 100644 --- a/apps/kimi-code/src/tui/constant/tips.ts +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -32,6 +32,7 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, { text: '/web: use the Web UI for a better experience', solo: true }, { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, ]; export const ALL_TIPS: readonly ToolbarTip[] = [ diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 03199b65a..e5a3ba3fc 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -32,6 +32,7 @@ export interface AuthFlowHost { fetchSessions(): Promise; updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise; + refreshPluginCommands(session?: Session): Promise; } export class AuthFlowController { @@ -96,6 +97,7 @@ export class AuthFlowController { void host.fetchSessions(); host.updateTerminalTitle(); void host.refreshSkillCommands(host.session); + void host.refreshPluginCommands(host.session); } async clearActiveSessionAfterLogout(): Promise { @@ -107,6 +109,7 @@ export class AuthFlowController { sessionTitle: null, }); await this.host.refreshSkillCommands(); + await this.host.refreshPluginCommands(); } async refreshConfigAfterLogin(): Promise { diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts index 2999845d6..ac1fd8c11 100644 --- a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -2,11 +2,7 @@ import type { TUI } from '@earendil-works/pi-tui'; import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; -import { - FOCUS_DEBOUNCE_MS, - HINT_COOLDOWN_MS, - HINT_DISPLAY_MS, -} from '../constant/clipboard-image-hint'; +import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint'; import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; import type { FooterComponent } from '../components/chrome/footer'; @@ -26,10 +22,19 @@ export class ClipboardImageHintController { private disposeInputListener: (() => void) | undefined; private debounceTimer: ReturnType | undefined; private clearHintTimer: ReturnType | undefined; - private lastHintAtMs = 0; private lastHintText: string | undefined; private checkGeneration = 0; private focused = true; + // Whether the controller has completed its first clipboard observation since + // start. The first observation only establishes a baseline: an image already + // in the clipboard when the session starts is not "new", so it must not + // trigger a hint during initialization. + private initialized = false; + // Whether a detected clipboard image is allowed to trigger a hint. After + // showing a hint for an image it disarms so the same lingering image does + // not nag on every focus. A focus check that finds the clipboard empty + // re-arms it, so the next genuinely new image notifies again. + private armed = true; constructor(host: ClipboardImageHintHost) { this.host = host; @@ -39,6 +44,7 @@ export class ClipboardImageHintController { this.disposeInputListener = this.host.ui.addInputListener((data) => { this.handleInput(data); }); + void this.establishInitialBaseline(); } stop(): void { @@ -49,7 +55,8 @@ export class ClipboardImageHintController { this.checkGeneration += 1; this.clearOwnedHint(); - this.lastHintAtMs = 0; + this.initialized = false; + this.armed = true; } private handleInput(data: string): void { @@ -94,10 +101,28 @@ export class ClipboardImageHintController { this.lastHintText = undefined; } + private async establishInitialBaseline(): Promise { + if (!this.host.getModelSupportsImage()) return; + + this.checkGeneration += 1; + const generation = this.checkGeneration; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + + this.initialized = true; + this.armed = !hasImage; + } + private async runCheck(generation: number): Promise { if (!this.focused) return; if (!this.host.getModelSupportsImage()) return; - if (Date.now() - this.lastHintAtMs < HINT_COOLDOWN_MS) return; let hasImage = false; try { @@ -108,14 +133,32 @@ export class ClipboardImageHintController { if (generation !== this.checkGeneration) return; if (!this.focused) return; - if (!hasImage) return; + + // First observation after start only establishes the baseline. An image + // already in the clipboard when the session began is not "new", so we + // record the state and stay quiet instead of nagging during initialization. + if (!this.initialized) { + this.initialized = true; + this.armed = !hasImage; + return; + } + + if (!hasImage) { + // Clipboard holds no image, so the next image that appears is a new one + // worth notifying about. Re-arm and bail out. + this.armed = true; + return; + } + + // Same image we already notified about — stay quiet until it changes. + if (!this.armed) return; const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; this.clearClearHintTimer(); this.lastHintText = hintText; + this.armed = false; this.host.footer.setTransientHint(hintText); this.host.requestRender(); - this.lastHintAtMs = Date.now(); this.clearHintTimer = setTimeout(() => { this.clearOwnedHint(); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index bc5b922b0..db3717f83 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -7,13 +7,14 @@ import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/exte import { CTRL_C_HINT, CTRL_D_HINT, + DOUBLE_ESC_WINDOW_MS, EXIT_CONFIRM_WINDOW_MS, LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE, } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; -import type { PendingExit } from '../types'; +import type { PendingExit, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; @@ -25,7 +26,7 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; steerMessage(session: Session, input: string[]): void; - recallLastQueued(): string | undefined; + recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; @@ -33,15 +34,19 @@ export interface EditorKeyboardHost { toggleToolOutputExpansion(): void; toggleTodoPanelExpansion(): void; detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; hideSessionPicker(): void; + openUndoSelector(): void; stop(exitCode?: number): Promise; handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; } export class EditorKeyboardController { private pendingExit: PendingExit | null = null; + private pendingUndoEsc: { readonly timer: ReturnType } | null = null; constructor( private readonly host: EditorKeyboardHost, @@ -61,6 +66,10 @@ export class EditorKeyboardController { host.updateEditorBorderHighlight(text); }; + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); + }; + editor.onCtrlC = () => { if (host.cancelInFlight !== undefined) { const cancel = host.cancelInFlight; @@ -72,6 +81,9 @@ export class EditorKeyboardController { if (host.state.appState.isCompacting) { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentCompaction(); return; } @@ -88,10 +100,7 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); - if (editor.getText().length > 0) { - editor.setText(''); - return; - } + if (this.clearEditorTextIfPresent()) return; this.cancelCurrentStream(); return; @@ -122,18 +131,30 @@ export class EditorKeyboardController { if (this.pendingExit) this.clearPendingExit(); if (host.state.activeDialog === 'session-picker') { host.hideSessionPicker(); + this.clearPendingUndoEsc(); return; } if (host.state.appState.isCompacting) { this.cancelCurrentCompaction(); + this.clearPendingUndoEsc(); return; } if (host.btwPanelController.closeOrCancel()) { + this.clearPendingUndoEsc(); return; } if (host.state.appState.streamingPhase !== 'idle') { this.cancelCurrentStream(); + this.clearPendingUndoEsc(); + return; } + // Idle: a second Esc within the double-tap window opens the undo selector. + if (this.pendingUndoEsc !== null) { + this.clearPendingUndoEsc(); + host.openUndoSelector(); + return; + } + this.armPendingUndoEsc(); }; editor.onShiftTab = () => { @@ -147,6 +168,10 @@ export class EditorKeyboardController { host.handlePlanToggle(next); }; + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + editor.onOpenExternalEditor = () => { host.track('shortcut_editor'); void this.openExternalEditor(); @@ -168,20 +193,30 @@ export class EditorKeyboardController { }; editor.onCtrlS = () => { - if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; const text = editor.getText().trim(); - const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.clearQueuedMessages(); + const editorIsBash = editor.inputMode === 'bash'; + + // Bash commands (`! …`) are not steerable: keep them queued so they run + // after the current task instead of being injected into the turn as text. + const queued = host.state.queuedMessages; + const steerable = queued.filter((m) => m.mode !== 'bash'); + host.state.queuedMessages = queued.filter((m) => m.mode === 'bash'); const parts: string[] = []; - for (const q of queuedTexts) { - const trimmed = q.trim(); + for (const m of steerable) { + const trimmed = m.text.trim(); if (trimmed.length > 0) parts.push(trimmed); } - if (text.length > 0) parts.push(text); + if (!editorIsBash && text.length > 0) parts.push(text); if (parts.length > 0) { - editor.setText(''); + if (!editorIsBash) editor.setText(''); const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); @@ -194,6 +229,8 @@ export class EditorKeyboardController { }; editor.onCtrlB = (): boolean => { + // Shell command execution is treated as a streaming phase ('shell'), so + // this gate already covers it; only idle + not-compacting falls through. if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { return false; } @@ -219,7 +256,14 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { - editor.setText(recalled); + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + const mode = recalled.mode ?? 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } host.updateQueueDisplay(); host.state.ui.requestRender(); return true; @@ -239,6 +283,22 @@ export class EditorKeyboardController { this.pendingExit = null; } + private armPendingUndoEsc(): void { + this.clearPendingUndoEsc(); + const timer = setTimeout(() => { + if (this.pendingUndoEsc?.timer === timer) { + this.pendingUndoEsc = null; + } + }, DOUBLE_ESC_WINDOW_MS); + this.pendingUndoEsc = { timer }; + } + + private clearPendingUndoEsc(): void { + if (!this.pendingUndoEsc) return; + clearTimeout(this.pendingUndoEsc.timer); + this.pendingUndoEsc = null; + } + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { this.clearPendingExit(); this.host.state.footer.setTransientHint(hint); @@ -254,7 +314,17 @@ export class EditorKeyboardController { this.host.state.ui.requestRender(); } + private clearEditorTextIfPresent(): boolean { + const editor = this.host.state.editor; + if (editor.getText().length === 0) return false; + editor.setText(''); + return true; + } + private cancelCurrentStream(): void { + // Cancel any running `!` shell command (treated as a streaming phase) in + // addition to the agent turn, so Esc / Ctrl+C interrupts it too. + this.host.cancelRunningShellCommand(); void this.host.session?.cancel(); } 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 075e87bca..33cec0ff2 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -17,6 +17,7 @@ import type { Session, SessionMetaUpdatedEvent, SkillActivatedEvent, + PluginCommandActivatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -106,6 +107,8 @@ export interface SessionEventHost { restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -132,6 +135,7 @@ export class SessionEventHandler { backgroundTaskTranscriptedTerminal: Set = new Set(); renderedSkillActivationIds: Set = new Set(); + renderedPluginCommandActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); mcpServers: Map = new Map(); @@ -148,6 +152,7 @@ export class SessionEventHandler { this.backgroundTaskTranscriptedTerminal.clear(); this.subAgentEventHandler.resetRuntimeState(); this.renderedSkillActivationIds.clear(); + this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; @@ -242,6 +247,8 @@ export class SessionEventHandler { case 'turn.step.completed': this.handleStepCompleted(event); break; case 'turn.step.retrying': break; case 'tool.progress': this.handleToolProgress(event); break; + case 'shell.output': this.host.handleShellOutput(event); break; + case 'shell.started': this.host.handleShellStarted(event); break; case 'assistant.delta': this.handleAssistantDelta(event); break; case 'hook.result': this.handleHookResult(event); break; case 'thinking.delta': this.handleThinkingDelta(event); break; @@ -252,6 +259,7 @@ export class SessionEventHandler { case 'session.meta.updated': this.handleSessionMetaChanged(event); break; case 'goal.updated': this.handleGoalUpdated(event); break; case 'skill.activated': this.handleSkillActivated(event); break; + case 'plugin_command.activated': this.handlePluginCommandActivated(event); break; case 'error': this.handleSessionError(event); break; case 'warning': this.handleSessionWarning(event); break; case 'compaction.started': this.handleCompactionBegin(event); break; @@ -388,7 +396,14 @@ export class SessionEventHandler { private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); - if (text !== undefined) this.host.showStatus(text); + if (text === undefined) return; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: String(event.turnId), + renderMode: 'plain', + content: text, + }); } private markActiveAgentSwarmsCancelled(): void { @@ -397,9 +412,10 @@ export class SessionEventHandler { private isAnthropicSessionActive(): boolean { const { state } = this.host; - const providerKey = state.appState.availableModels[state.appState.model]?.provider; - if (providerKey === undefined) return false; - return state.appState.availableProviders[providerKey]?.type === 'anthropic'; + const model = state.appState.availableModels[state.appState.model]; + if (model === undefined) return false; + if (model.protocol === 'anthropic') return true; + return state.appState.availableProviders[model.provider]?.type === 'anthropic'; } private handleStepInterrupted(event: TurnStepInterruptedEvent): void { @@ -927,6 +943,25 @@ export class SessionEventHandler { }); } + private handlePluginCommandActivated(event: PluginCommandActivatedEvent): void { + if (this.renderedPluginCommandActivationIds.has(event.activationId)) return; + this.renderedPluginCommandActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'plugin_command', + turnId: undefined, + renderMode: 'plain', + content: `/${event.pluginId}:${event.commandName}`, + pluginCommandData: { + activationId: event.activationId, + pluginId: event.pluginId, + commandName: event.commandName, + args: event.commandArgs, + trigger: event.trigger, + }, + }); + } + private handleCompactionBegin(event: CompactionStartedEvent): void { this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.setAppState({ diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 4a13fe373..b4f23bc48 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -21,6 +22,7 @@ import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; import { appStateFromResumeAgent, backgroundOrigin, @@ -35,10 +37,12 @@ import { replayBackgroundProjection, replayEntry, skillActivationFromOrigin, + pluginCommandFromOrigin, toolCallFromReplayMessage, toolResultOutput, type ReplayRenderContext, type SkillActivationProjection, + type PluginCommandProjection, } from '../utils/message-replay'; import type { StreamingUIController } from './streaming-ui'; import type { SessionEventHandler } from './session-event-handler'; @@ -55,6 +59,23 @@ export interface SessionReplayHost { setAppState(patch: Partial): void; showError(msg: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + mergeAllTurnSteps(): void; +} + +function extractBashTag( + text: string, + tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', +): string | undefined { + const match = new RegExp(`<${tag}>([\\s\\S]*?)`).exec(text); + return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); } export class SessionReplayRenderer { @@ -72,6 +93,7 @@ export class SessionReplayRenderer { this.hydrateSnapshot(main); this.renderRecords(main); this.applyTerminalBackgroundAgentStatuses(main); + this.host.mergeAllTurnSteps(); return true; } catch (error) { const message = formatErrorMessage(error); @@ -249,6 +271,28 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'injection') { return; } + if (message.origin?.kind === 'shell_command') { + // A `!` command, replayed from records. Unwrap the XML tags back into the + // same `$ cmd` + output view the live editor produced. (Must NOT fall into + // the `injection` branch above — that returns without rendering.) + this.flushAssistant(context); + const text = contentPartsToText(message.content); + if (message.origin.phase === 'input') { + const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { + bullet: '', + }), + ); + } else { + const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); + const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim(); + const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError); + this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain')); + } + return; + } if (message.origin?.kind === 'cron_job') { this.renderCronJob(context, message); return; @@ -280,6 +324,14 @@ export class SessionReplayRenderer { } return; } + const pluginCommand = pluginCommandFromOrigin(message.origin); + if (pluginCommand !== undefined) { + this.renderPluginCommand(context, pluginCommand); + if (message.origin?.kind === 'plugin_command' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } this.advanceTurn(context); this.host.appendTranscriptEntry( @@ -377,6 +429,32 @@ export class SessionReplayRenderer { }); } + private renderPluginCommand( + context: ReplayRenderContext, + command: PluginCommandProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.pluginCommandActivationIds.has(command.activationId)) return; + if (sessionEventHandler.renderedPluginCommandActivationIds.has(command.activationId)) return; + context.pluginCommandActivationIds.add(command.activationId); + sessionEventHandler.renderedPluginCommandActivationIds.add(command.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'plugin_command', + `/${command.pluginId}:${command.commandName}`, + 'plain', + ), + pluginCommandData: { + activationId: command.activationId, + pluginId: command.pluginId, + commandName: command.commandName, + args: command.commandArgs, + trigger: command.trigger, + }, + }); + } + private renderCompaction(context: ReplayRenderContext, record: CompactionReplayRecord): void { this.flushAssistant(context); if (record.result === undefined) return; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 4581fc326..cb620801e 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -35,6 +35,7 @@ export interface StreamingUIHost { deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; } export class StreamingUIController { @@ -600,12 +601,16 @@ export class StreamingUIController { const block = this._streamingBlock; if (block !== null) { block.entry.content = fullText; - block.component.updateContent(fullText); + block.component.updateContent(fullText, { transient: true }); this.host.state.ui.requestRender(); } } onStreamingTextEnd(): void { + const block = this._streamingBlock; + if (block !== null) { + block.component.updateContent(block.entry.content, { transient: false }); + } this._streamingBlock = null; } @@ -634,6 +639,7 @@ export class StreamingUIController { this._activeThinkingComponent.finalize(); this._activeThinkingComponent = undefined; this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -680,6 +686,7 @@ export class StreamingUIController { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); return; } @@ -694,6 +701,7 @@ export class StreamingUIController { state.transcriptContainer.addChild(completed); state.ui.requestRender(); } + this.host.mergeCurrentTurnSteps(); } setTodoList(todos: readonly TodoItem[]): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 24a72043f..23ccbbcfc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -35,6 +35,7 @@ import { BannerProvider } from './banner/banner-provider'; import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, buildSkillSlashCommands, isExperimentalFlagEnabled, setExperimentalFeatures, @@ -74,11 +75,14 @@ import { GoalSetMessageComponent, } from './components/messages/goal-panel'; import { SkillActivationComponent } from './components/messages/skill-activation'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; import { ThinkingComponent } from './components/messages/thinking'; +import { StepSummaryComponent } from './components/messages/step-summary'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; @@ -122,7 +126,7 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { isExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; @@ -135,7 +139,17 @@ import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; -import { markTranscriptComponent } from './utils/transcript-component-metadata'; +import { getTranscriptComponentEntry, markTranscriptComponent } from './utils/transcript-component-metadata'; +import { + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; +import { formatBashOutputForDisplay } from './utils/shell-output'; import { nextTranscriptId } from './utils/transcript-id'; export type { TUIState } from './tui-state'; @@ -189,6 +203,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + inputMode: 'prompt', swarmMode: false, thinking: false, contextUsage: 0, @@ -231,6 +246,8 @@ export class KimiTUI { private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); + private pluginCommands: readonly KimiSlashCommand[] = []; + readonly pluginCommandMap = new Map(); private readonly imageStore = new ImageAttachmentStore(); private fdPath: string | null = detectFdPath(); private fdDownloadStarted = false; @@ -251,6 +268,14 @@ export class KimiTUI { private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = undefined; private lastHistoryContent: string | undefined; + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly btwPanelController: BtwPanelController; @@ -343,7 +368,7 @@ export class KimiTUI { const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => isExperimentalFlagEnabled(command.experimentalFlag), ); - return [...builtins, ...this.skillCommands]; + return [...builtins, ...this.skillCommands, ...this.pluginCommands]; } private setupAutocomplete(): void { @@ -364,6 +389,7 @@ export class KimiTUI { this.state.appState.workDir, this.fdPath, this.state.appState.additionalDirs, + () => this.state.appState.inputMode, ); this.state.editor.setAutocompleteProvider(provider); @@ -405,6 +431,29 @@ export class KimiTUI { this.setupAutocomplete(); } + async refreshPluginCommands(session?: Session): Promise { + if (session === undefined) { + this.pluginCommands = []; + this.pluginCommandMap.clear(); + this.setupAutocomplete(); + return; + } + + let defs; + try { + defs = await session.listPluginCommands(); + } catch { + return; + } + const pluginSlashCommands = buildPluginSlashCommands(defs); + this.pluginCommands = pluginSlashCommands.commands; + this.pluginCommandMap.clear(); + for (const [commandName, body] of pluginSlashCommands.commandMap) { + this.pluginCommandMap.set(commandName, body); + } + this.setupAutocomplete(); + } + // ========================================================================= // Lifecycle // ========================================================================= @@ -591,6 +640,7 @@ export class KimiTUI { this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); } private async showSessionWarnings(session: Session): Promise { @@ -831,16 +881,156 @@ export class KimiTUI { void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); } + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.updateEditorBorderHighlight(); + } + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); return; } - void this.persistInputHistory(text); + // Shell commands (`! …`) are not prompts — keep them out of input history + // so ↑ recall never surfaces a bare command stripped of its `!`. + if (!wasBashMode) { + void this.persistInputHistory(text); + } + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.runShellCommandFromInput(text); + return; + } slashCommands.dispatchInput(this, text); } + private runShellCommandFromInput(command: string): void { + const session = this.session; + if (session === undefined) { + this.showError('No active session for shell command.'); + return; + } + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + const session = this.session; + if (session === undefined) return; + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + sendNormalUserInput(text: string): void { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { @@ -922,18 +1112,22 @@ export class KimiTUI { } } - recallLastQueued(): string | undefined { + recallLastQueued(): QueuedMessage | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); - return last.text; + return last; } // ========================================================================= // Session Requests / Queues // ========================================================================= - private enqueueMessage(text: string, options?: SendMessageOptions): void { + private enqueueMessage( + text: string, + options?: SendMessageOptions, + mode?: 'prompt' | 'bash', + ): void { this.state.queuedMessages.push({ text, agentId: this.harness.interactiveAgentId, @@ -942,6 +1136,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -970,6 +1165,10 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { + if (item.mode === 'bash') { + this.runShellCommandFromInput(item.text); + return; + } this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { this.sendMessageInternal(session, item.text, { parts: item.parts, @@ -1013,6 +1212,19 @@ export class KimiTUI { }); } + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void { + this.beginSessionRequest(); + void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); + }); + } + private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { if ( this.deferUserMessages || @@ -1379,6 +1591,7 @@ export class KimiTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the switched session usable even if dynamic skills fail */ } @@ -1416,6 +1629,7 @@ export class KimiTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); } catch { /* keep the reloaded session usable even if dynamic skills fail */ } @@ -1457,6 +1671,7 @@ export class KimiTUI { } try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the new session usable even if dynamic skills fail */ } @@ -1500,7 +1715,7 @@ 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, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( @@ -1508,6 +1723,11 @@ export class KimiTUI { entry.skillArgs, entry.skillTrigger, ); + case 'plugin_command': { + const data = entry.pluginCommandData; + if (data === undefined) return null; + return new PluginCommandComponent(data.pluginId, data.commandName, data.args); + } case 'cron': return new CronMessageComponent(entry.content, entry.cronData ?? {}); case 'goal': @@ -1568,6 +1788,10 @@ export class KimiTUI { if (component) { markTranscriptComponent(component, entry); this.state.transcriptContainer.addChild(component); + } + const trimmed = this.trimTranscriptWindow(); + const merged = this.mergeCurrentTurnSteps(); + if (component || trimmed || merged) { this.state.ui.requestRender(); } } @@ -1629,6 +1853,12 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + // Dispose disposable children (e.g. ShellRunComponent's 1s timer) before + // dropping them, so a /clear or session switch can't leak intervals that + // keep firing requestRender on a removed component. + for (const child of this.state.transcriptContainer.children) { + if (hasDispose(child)) child.dispose(); + } this.state.transcriptContainer.clear(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); @@ -1638,6 +1868,223 @@ export class KimiTUI { this.renderWelcome(); } + private isTurnBoundaryComponent(child: Component): boolean { + if ( + !(child instanceof UserMessageComponent) && + !(child instanceof SkillActivationComponent) && + !(child instanceof PluginCommandComponent) + ) { + return false; + } + const entry = getTranscriptComponentEntry(child); + if (entry === undefined) return false; + // Live user messages / slash activations have an undefined turnId; replayed + // ones get a `replay:N` turnId. Both start a new turn. Steer messages carry + // a defined non-replay turnId and are not boundaries. + return entry.turnId === undefined || entry.turnId.startsWith('replay:'); + } + + private trimTranscriptWindow(): boolean { + if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false; + // Session replay already caps history to its own turn limit; trimming during + // replay would shrink it further and fight that limit. + if (this.state.appState.isReplaying) return false; + + const children = this.state.transcriptContainer.children; + + // Trim whole turns by *position* in the child list rather than by entry + // lookup — otherwise only the (registered) user message would be removed and + // the rest of the turn would be left behind. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + + const turns = groupTurns(this.state.transcriptEntries); + + const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS); + if (toRemove.size === 0) return false; + + let boundariesToRemove = 0; + for (const entry of toRemove) { + if ( + (entry.kind === 'user' || + entry.kind === 'skill_activation' || + entry.kind === 'plugin_command') && + entry.turnId === undefined + ) { + boundariesToRemove++; + } + } + if (boundariesToRemove === 0) { + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + let boundariesSeen = 0; + let cutoff = 0; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) { + if (boundariesSeen === boundariesToRemove) { + cutoff = i; + break; + } + boundariesSeen++; + } + } + + const componentsToRemove: Component[] = []; + for (let i = 0; i < cutoff; i++) { + const child = children[i]!; + if (child instanceof WelcomeComponent) continue; + componentsToRemove.push(child); + } + for (const child of componentsToRemove) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.state.transcriptContainer.removeChild(child); + if (hasDispose(child)) child.dispose(); + } + + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + mergeCurrentTurnSteps(): boolean { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false; + const children = this.state.transcriptContainer.children; + + // Find the start of the current turn (last turn-starting user message). + let turnStart = -1; + for (let i = children.length - 1; i >= 0; i--) { + if (this.isTurnBoundaryComponent(children[i]!)) { + turnStart = i; + break; + } + } + if (turnStart < 0) return false; + + // Locate an existing summary, the assistant message, and the mergeable steps. + let summaryIndex = -1; + const stepIndices: number[] = []; + for (let i = turnStart + 1; i < children.length; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) { + summaryIndex = i; + continue; + } + if (child instanceof AssistantMessageComponent) continue; + stepIndices.push(i); + } + + if (stepIndices.length <= TRANSCRIPT_KEEP_RECENT_STEPS) return false; + const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; + const toMergeIndices = stepIndices.slice(0, mergeCount); + + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + if (thinkingCount === 0 && toolCount === 0) return false; + + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount); + } + + // Rebuild children: keep everything except the merged steps, with the summary + // sitting right after the user message. + const toMergeSet = new Set(toMergeIndices); + const newChildren: Component[] = []; + for (let i = 0; i <= turnStart; i++) newChildren.push(children[i]!); + newChildren.push(summary); + for (let i = turnStart + 1; i < children.length; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (hasDispose(child)) child.dispose(); + } + + children.splice(0, children.length, ...newChildren); + return true; + } + + mergeAllTurnSteps(): void { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return; + const children = this.state.transcriptContainer.children; + + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + if (boundaries.length === 0) return; + + const newChildren: Component[] = []; + const toDispose: Component[] = []; + for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!); + + for (let t = 0; t < boundaries.length; t++) { + const turnStart = boundaries[t]!; + const turnEnd = t + 1 < boundaries.length ? boundaries[t + 1]! : children.length; + newChildren.push(children[turnStart]!); + + let summaryIndex = -1; + const stepIndices: number[] = []; + for (let i = turnStart + 1; i < turnEnd; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) summaryIndex = i; + else if (child instanceof AssistantMessageComponent) continue; + else stepIndices.push(i); + } + + if (stepIndices.length > TRANSCRIPT_KEEP_RECENT_STEPS) { + const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; + const toMergeIndices = stepIndices.slice(0, mergeCount); + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount); + } + newChildren.push(summary); + for (const idx of toMergeIndices) toDispose.push(children[idx]!); + const toMergeSet = new Set(toMergeIndices); + for (let i = turnStart + 1; i < turnEnd; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + } else { + for (let i = turnStart + 1; i < turnEnd; i++) newChildren.push(children[i]!); + } + } + + for (const child of toDispose) { + if (hasDispose(child)) child.dispose(); + } + children.splice(0, children.length, ...newChildren); + } + showStatus(message: string, color?: ColorToken): void { this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color)); this.state.ui.requestRender(); @@ -1670,6 +2117,9 @@ export class KimiTUI { spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } @@ -1795,6 +2245,11 @@ export class KimiTUI { if (this.state.livePane.pendingQuestion !== null) return 'hidden'; const streamingPhase = this.state.appState.streamingPhase; + + // A running `!` shell command shows the moon spinner (same as `waiting`) + // until it finishes, signalling that input is busy / queued. + if (streamingPhase === 'shell') return 'waiting'; + if (this.state.livePane.mode === 'idle') { if (streamingPhase === 'thinking' || streamingPhase === 'composing') { return streamingPhase; @@ -1821,10 +2276,27 @@ export class KimiTUI { toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; - for (const child of this.state.transcriptContainer.children) { - if (isExpandable(child)) { - child.setExpanded(this.state.toolOutputExpanded); - } + const children = this.state.transcriptContainer.children; + + // A component is expandable only if it sits at or after the start of the + // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most + // recent `expandTurns` turns. Position-based so it also covers streaming + // components that have no entry in the metadata map. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + const expandCutoff = + TRANSCRIPT_EXPAND_TURNS <= 0 + ? children.length + : boundaries.length > TRANSCRIPT_EXPAND_TURNS + ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! + : 0; + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!isExpandable(child)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } this.state.ui.requestRender(); } @@ -1834,7 +2306,49 @@ export class KimiTUI { this.state.ui.requestRender(); } + private async detachRunningShellCommand(): Promise { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by agent-core via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + async detachCurrentForegroundTask(): Promise { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + const session = this.session; if (session === undefined) { this.showError(NO_ACTIVE_SESSION_MESSAGE); @@ -1901,10 +2415,12 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => - currentTheme.fg(highlighted ? 'primary' : 'border', s); + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); this.state.ui.requestRender(); } @@ -2161,6 +2677,10 @@ export class KimiTUI { this.restoreEditor(); } + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + private mountSessionPicker(options: { readonly onCancel: () => void; readonly onCtrlC?: () => void; diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 215c54bbb..66f9819c3 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -71,6 +71,12 @@ export interface ColorPalette { /** User message: bullet & text, skill-activation name. The one role colour * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; + + // ── Shell mode ── + /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the + * echoed `$ command` line. Its own hue (violet), distinct from + * plan-mode (primary) and the user role (roleUser). */ + shellMode: string; } export const darkColors: ColorPalette = { @@ -97,6 +103,7 @@ export const darkColors: ColorPalette = { diffMeta: '#888888', roleUser: '#FFCB6B', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { @@ -123,6 +130,7 @@ export const lightColors: ColorPalette = { diffMeta: '#5F5F5F', roleUser: '#9A4A00', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; 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 dec6ab253..d03f309fa 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -22,7 +22,8 @@ import { currentTheme } from './theme'; // 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(): MarkdownTheme { +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); return { @@ -44,6 +45,8 @@ export function createMarkdownTheme(): MarkdownTheme { strikethrough: (text) => chalk.strikethrough(text), underline: (text) => chalk.underline(text), highlightCode: (code: string, lang?: string) => { + if (transient) return code.split('\n'); + const normalizedLang = lang?.trim().toLowerCase(); const language = normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json index 5e320992d..a411088f9 100644 --- a/apps/kimi-code/src/tui/theme/theme-schema.json +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -45,7 +45,8 @@ "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" } + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } }, "additionalProperties": { "type": "string", diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 8625d31be..6a9594f01 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -60,11 +60,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const terminal = new ProcessTerminal(); const ui = new TUI(terminal); - // Content shrinks (e.g. a tall inline image is replaced by a short - // placeholder or text) can leave stale rows behind because pi-tui's - // differential renderer does not clear them by default. Enable clearing so - // artifacts like duplicated input boxes do not accumulate. - ui.setClearOnShrink(true); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index b8249e92c..77313799f 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -30,6 +30,8 @@ export interface AppState { sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; swarmMode: boolean; thinking: boolean; contextUsage: number; @@ -37,7 +39,7 @@ export interface AppState { maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; theme: ThemeName; version: string; @@ -137,11 +139,20 @@ export type TranscriptEntryKind = | 'thinking' | 'status' | 'skill_activation' + | 'plugin_command' | 'cron' | 'goal'; export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; +export interface PluginCommandTranscriptData { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly args?: string; + readonly trigger: 'user-slash'; +} + export interface TranscriptEntry { id: string; kind: TranscriptEntryKind; @@ -150,6 +161,8 @@ export interface TranscriptEntry { content: string; color?: ColorToken; detail?: string; + /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ + bullet?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; compactionData?: CompactionTranscriptData; @@ -160,6 +173,7 @@ export interface TranscriptEntry { skillName?: string; skillArgs?: string; skillTrigger?: SkillActivationTrigger; + pluginCommandData?: PluginCommandTranscriptData; } export type LivePaneMode = @@ -180,6 +194,9 @@ export interface QueuedMessage { readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + /** `bash` for a `!` shell command queued while another command is running; + * undefined (=`prompt`) for a normal message. */ + readonly mode?: 'prompt' | 'bash'; } export const INITIAL_LIVE_PANE: LivePaneState = { @@ -216,6 +233,7 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index b1478697c..99441472b 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -32,6 +32,7 @@ export interface ReplayRenderContext { toolCalls: Map; completedToolCallIds: Set; skillActivationIds: Set; + pluginCommandActivationIds: Set; suppressNextPlanModeOffNotice: boolean; } @@ -42,6 +43,14 @@ export interface SkillActivationProjection { readonly trigger: SkillActivationTrigger; } +export interface PluginCommandProjection { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + export interface ReplayBackgroundProjection { readonly backgroundAgentMetadata: ReadonlyMap; } @@ -114,6 +123,7 @@ export function createReplayRenderContext(): ReplayRenderContext { toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), + pluginCommandActivationIds: new Set(), suppressNextPlanModeOffNotice: false, }; } @@ -135,7 +145,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string } = {}, + extras: { detail?: string; bullet?: string } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -144,6 +154,7 @@ export function replayEntry( renderMode, content, detail: extras.detail, + bullet: extras.bullet, }; } @@ -212,6 +223,19 @@ export function skillActivationFromOrigin( }; } +export function pluginCommandFromOrigin( + origin: PromptOrigin | undefined, +): PluginCommandProjection | undefined { + if (origin?.kind !== 'plugin_command') return undefined; + return { + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }; +} + export function formatHookResultMessageForTranscript( text: string, fallbackEvent: string, @@ -250,6 +274,11 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { return true; case 'skill_activation': return message.origin.trigger === 'user-slash'; + case 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; case 'background_task': case 'compaction_summary': case 'cron_job': diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index eaddeae65..d475313ae 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } } +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + const url = new URL(trimmed); + return ( + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/') + ); + } catch { + return false; + } +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index a25c4b7cf..926b458e1 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -1,22 +1,17 @@ import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - fetchCustomRegistry, - fetchManagedKimiCodeModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - removeCustomRegistryProvider, - resolveKimiCodeRuntimeAuth, - type CustomRegistrySource, - type ManagedKimiConfigShape, + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiConfig, KimiConfigPatch, ModelAlias, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedKimiConfigShape`-typed host directly. + */ export interface RefreshProviderHost { getConfig(): Promise; removeProvider(providerId: string): Promise; @@ -24,542 +19,25 @@ export interface RefreshProviderHost { resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; } -export interface ProviderChange { - readonly providerId: string; - /** User-facing name when available. */ - readonly providerName: string; - readonly added: number; - readonly removed: number; -} - -export interface RefreshResult { - /** Providers whose model list actually changed. */ - readonly changed: readonly ProviderChange[]; - /** Providers whose model list stayed identical after refresh. */ - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; -} - -export type RefreshProviderScope = 'all' | 'oauth'; - -export interface RefreshProviderOptions { - readonly scope?: RefreshProviderScope; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source; - if (candidate['kind'] !== 'apiJson') return undefined; - const url = candidate['url']; - const apiKey = candidate['apiKey']; - if (typeof url !== 'string' || url.length === 0) return undefined; - if (typeof apiKey !== 'string') return undefined; - return { kind: 'apiJson', url, apiKey }; -} - -function customRegistrySourceKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url]); -} - -function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url, source.apiKey]); -} - -async function fetchCustomRegistryFromSources( - sources: readonly CustomRegistrySource[], -): Promise<{ - readonly entries: Awaited>; - readonly source: CustomRegistrySource; -}> { - let lastError: unknown; - for (const source of sources) { - try { - return { - entries: await fetchCustomRegistry(source), - source, - }; - } catch (error) { - lastError = error; - } - } - if (lastError instanceof Error) throw lastError; - if (typeof lastError === 'string') throw new Error(lastError); - throw new Error('No custom registry sources configured.'); -} - -function asManaged(config: KimiConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; -} - -function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet): Set { - const ids = new Set(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) { - ids.add(alias.model); - } - } - return ids; -} - -function providerAliasKeys(config: KimiConfig, providerId: string): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) { - keys.add(alias); - } - } - return keys; -} - -function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -interface ProviderModelSnapshot { - readonly alias: string; - readonly model: ModelAlias; -} - -// Compare the full model metadata for the relevant aliases, not just model IDs: -// a registry can change capabilities (e.g. enabling reasoning) without changing -// any model ID. Spreading the whole `ModelAlias` keeps this in sync with the -// schema automatically; only `capabilities` needs normalizing because its order -// is not meaningful. -function providerModelSnapshot( - config: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): string { - const snapshots: ProviderModelSnapshot[] = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} - -function providerModelsEqual( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasKeys: ReadonlySet, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} - -function providerConfigSnapshot(config: KimiConfig, providerId: string): string { - return JSON.stringify(config.providers[providerId] ?? null); -} - -function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean { - return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); -} - -function providerRefreshAliasKeys( - config: KimiConfig, - nextConfig: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} - -function preserveUserProviderAliases( - config: KimiConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet, -): Record { - const preserved: Record = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; -} - -function restoreProviderAliases(config: KimiConfig, aliases: Record): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} - -function restoreDefaultSelection( - config: KimiConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - // 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 -// (e.g. the previously-selected model was dropped from the registry). The host's -// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` -// call handles disk cleanup while this drops the dangling reference in memory. -function clampDanglingDefault(config: KimiConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} - -function clearDefaultThinkingWhenDefaultRemoved( - config: KimiConfig, - previousDefaultModel: string | undefined, -): void { - if (previousDefaultModel !== undefined && config.defaultModel === undefined) { - config.defaultThinking = undefined; - } -} - -function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string { - const firstModel = models[0]; - if (firstModel === undefined) return ''; - - const existingDefault = config.defaultModel; - if (existingDefault !== undefined) { - const alias = config.models?.[existingDefault]; - if (alias !== undefined && alias.provider === providerId) { - const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } - } - } - return firstModel.id; -} +export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; +/** + * Refresh remote model metadata for the configured providers. Thin adapter over + * the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth` + * (which is also what the daemon's scheduled/manual refresh uses). + */ export async function refreshAllProviderModels( host: RefreshProviderHost, options: RefreshProviderOptions = {}, ): Promise { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - const scope = options.scope ?? 'all'; - - let config = await host.getConfig(); - - // ------------------------------------------------------------------------- - // 1. Managed Kimi Code (OAuth) - // ------------------------------------------------------------------------- - const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; - if ( - managedProvider !== undefined && - managedProvider.type === 'kimi' && - managedProvider.oauth !== undefined - ) { - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: managedProvider.baseUrl, - configuredOAuthRef: managedProvider.oauth, - }); - const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: auth.baseUrl, - }); - if (models.length > 0) { - const next = structuredClone(config); - applyManagedKimiCodeConfig(asManaged(next), { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - KIMI_CODE_PROVIDER_NAME, - `${KIMI_CODE_PLATFORM_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Kimi Code', - added, - removed, - }); - } - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - if (scope === 'oauth') { - return { changed, unchanged, failed }; - } - - // ------------------------------------------------------------------------- - // 2. Open Platforms (moonshot-cn, moonshot-ai, …) - // ------------------------------------------------------------------------- - const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); - for (const providerId of openPlatformIds) { - const platform = getOpenPlatformById(providerId); - if (platform === undefined) continue; - - const providerConfig = config.providers[providerId]; - if (providerConfig === undefined) continue; - const apiKey = providerConfig.apiKey; - if (typeof apiKey !== 'string' || apiKey.length === 0) continue; - - try { - let models = await fetchOpenPlatformModels(platform, apiKey); - models = filterModelsByPrefix(models, platform); - if (models.length === 0) continue; - - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((m) => m.id === selectedModelId); - if (selectedModel === undefined) continue; - const next = structuredClone(config); - applyOpenPlatformConfig(asManaged(next), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - providerId, - `${providerId}/`, - ); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(providerId); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 3. Custom Registry providers (grouped by URL, with API-key candidates) - // ------------------------------------------------------------------------- - const customSources = new Map< - string, + return refreshProviderModels( { - readonly sources: CustomRegistrySource[]; - readonly sourceKeys: Set; - readonly providerIds: string[]; - } - >(); - for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === KIMI_CODE_PROVIDER_NAME) continue; - if (isOpenPlatformId(providerId)) continue; - const source = readCustomRegistrySource(providerConfig); - if (source === undefined) continue; - const key = customRegistrySourceKey(source); - const sourceKey = customRegistrySourceCredentialKey(source); - const entry = customSources.get(key); - if (entry !== undefined) { - if (!entry.sourceKeys.has(sourceKey)) { - entry.sources.push(source); - entry.sourceKeys.add(sourceKey); - } - entry.providerIds.push(providerId); - } else { - customSources.set(key, { - sources: [source], - sourceKeys: new Set([sourceKey]), - providerIds: [providerId], - }); - } - } - - for (const { sources, providerIds } of customSources.values()) { - try { - const { entries, source } = await fetchCustomRegistryFromSources(sources); - // Build the whole batch on one clone so that several changed providers - // from the same source do not overwrite each other's aliases, and so the - // config we compare is exactly the config we persist. - const next = structuredClone(config); - const changedProviders: Array<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }> = []; - const providersToRemoveBeforeSet = new Set(); - let hasUnreportedConfigChange = false; - const remoteEntries = Object.values(entries); - const remoteEntriesByProviderId = new Map( - remoteEntries.map((entry) => [entry.id, entry]), - ); - const providerIdsToSync = new Set(providerIds); - for (const entry of remoteEntries) providerIdsToSync.add(entry.id); - - for (const providerId of providerIdsToSync) { - const entry = remoteEntriesByProviderId.get(providerId); - if (entry === undefined) { - const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); - removeCustomRegistryProvider(asManaged(next), providerId); - changedProviders.push({ - providerId, - providerName: providerId, - added: 0, - removed: oldIds.size, - }); - providersToRemoveBeforeSet.add(providerId); - continue; - } - - const existed = config.providers[providerId] !== undefined; - applyCustomRegistryProvider(asManaged(next), entry, source); - const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - if (existed) { - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - } - - if ( - existed && - providerModelsEqual(config, next, providerId, refreshedAliasKeys) && - providerConfigEqual(config, next, providerId) - ) { - unchanged.push(providerId); - } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - providersToRemoveBeforeSet.add(providerId); - hasUnreportedConfigChange = true; - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - if (existed) providersToRemoveBeforeSet.add(providerId); - } - } - - if (changedProviders.length > 0 || hasUnreportedConfigChange) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - for (const providerId of providersToRemoveBeforeSet) { - await host.removeProvider(providerId); - } - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - for (const change of changedProviders) { - changed.push({ - providerId: change.providerId, - providerName: change.providerName, - added: change.added, - removed: change.removed, - }); - } - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; + getConfig: () => host.getConfig(), + removeProvider: (providerId) => host.removeProvider(providerId), + setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), + resolveOAuthToken: (providerName, oauthRef) => + host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), + }, + options, + ); } diff --git a/apps/kimi-code/src/tui/utils/render-cache.ts b/apps/kimi-code/src/tui/utils/render-cache.ts new file mode 100644 index 000000000..628d80fae --- /dev/null +++ b/apps/kimi-code/src/tui/utils/render-cache.ts @@ -0,0 +1,28 @@ +/** + * Render-cache toggle for TUI message components. + * + * The transcript re-renders the entire component tree on every frame, and + * most message components rebuild their `render(width)` output from scratch + * even when their content has not changed. Caching the rendered lines (keyed + * on width + a dirty flag) turns an unchanged message's render into an O(1) + * array reference return, which is the dominant per-frame cost once the + * transcript grows long. + * + * The cache is on by default and can be disabled with + * `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks + * compare cached vs. uncached runs in the same process). + */ + +let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1'; + +export function isRenderCacheEnabled(): boolean { + return enabled; +} + +/** + * Override the cache at runtime. Intended for benchmarks / tests only; + * production code should not call this. + */ +export function setRenderCacheEnabled(value: boolean): void { + enabled = value; +} diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts new file mode 100644 index 000000000..3a482feb7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). + +// ESC [ — colours, cursor moves, clear, and +// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). +const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +// ESC ] … or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. +const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; +// ESC (and ESC ) — charset/keypad selection, +// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the +// CSI/OSC patterns, so it only catches sequences they didn't already consume. +const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; +// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … +// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; + +/** + * Strip every terminal control sequence from captured command output so it is + * safe to render via pi-tui (which does not sanitize on its own). + * + * Never throws: a bad or pathological input falls back to stripping only the + * C0 control characters, so rendering can never crash the TUI. + */ +export function sanitizeShellOutput(text: string): string { + if (typeof text !== 'string') return ''; + if (text.length === 0) return text; + try { + return text + .replace(OSC_PATTERN, '') + .replace(CSI_PATTERN, '') + .replace(ESC_SINGLE_PATTERN, '') + .replace(C0_CONTROL_PATTERN, ''); + } catch { + return text.replace(C0_CONTROL_PATTERN, ''); + } +} + +/** + * Format captured stdout/stderr for the transcript. Sanitizes both streams and + * dims them; stderr is red only on actual failure. + * + * Never throws: if anything goes wrong (theme lookup, huge input, …) it falls + * back to a best-effort plain view so a render error can never crash the TUI. + */ +export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { + try { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + const cleanStdout = sanitizeShellOutput(stdout).trimEnd(); + if (cleanStdout.length > 0) parts.push(dim(cleanStdout)); + const cleanStderr = sanitizeShellOutput(stderr).trimEnd(); + if (cleanStderr.length > 0) { + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); + } catch { + const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))] + .filter((s) => s.length > 0) + .join('\n'); + return plain.length > 0 ? plain : '(no output)'; + } +} diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts new file mode 100644 index 000000000..9a5a694cd --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,109 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 50); + +/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */ +export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3); + +/** Only trim once the window exceeds maxTurns by this much (avoids churn). */ +export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 10); + +/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ +export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30); + +export interface TranscriptTurn { + readonly turnId: string | undefined; + readonly entries: TranscriptEntry[]; +} + +/** + * Group consecutive entries into turns by `turnId`. Entries with the same + * non-undefined `turnId` that are adjacent belong to the same turn. + * + * Entries with an undefined `turnId` are buffered and attached to the *next* + * defined turn. This matters because a user message is appended (with + * `turnId: undefined`) before its turn actually starts, so without this + * buffering every user message would become its own single-entry turn at the + * front and get trimmed first. Any undefined entries left at the tail (no + * following turn) become their own turn. + */ +export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; + let current: TranscriptTurn | undefined; + let pendingUndefined: TranscriptEntry[] = []; + + for (const entry of entries) { + const turnId = entry.turnId; + if (turnId === undefined) { + pendingUndefined.push(entry); + continue; + } + if (current !== undefined && current.turnId === turnId) { + current.entries.push(entry); + } else { + current = { turnId, entries: [...pendingUndefined, entry] }; + pendingUndefined = []; + turns.push(current); + } + } + + if (pendingUndefined.length > 0) { + turns.push({ turnId: undefined, entries: pendingUndefined }); + } + + return turns; +} + +/** + * Decide which entries to destroy so the remaining turns fit within + * `maxTurns`. Returns an empty set when the turn count is within + * `maxTurns + hysteresis`. Oldest turns are removed first; the most recent + * turn is never removed (it is the active / just-finished turn). + */ +export function turnsToTrim( + turns: readonly TranscriptTurn[], + maxTurns: number, + hysteresis: number, +): Set { + const toRemove = new Set(); + + if (turns.length <= maxTurns + hysteresis) return toRemove; + + let remaining = turns.length; + // `turns.length - 1` keeps the most recent turn off-limits. + for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) { + const turn = turns[i]!; + for (const entry of turn.entries) toRemove.add(entry); + remaining--; + } + return toRemove; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts index 9844dc4fd..dc0f60886 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import type { ClipboardModule } from './clipboard-native'; @@ -9,6 +9,11 @@ export type RunCommand = ( args: string[], options?: RunCommandOptions, ) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; @@ -49,6 +54,74 @@ export function runCommand( return { ok: true, stdout }; } +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; } diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts index 54f5f3be1..8c344d02e 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -1,43 +1,6 @@ -import { - DEFAULT_LIST_TIMEOUT_MS, - isFileLikeNativeFormat, - isSupportedImageMimeType, - isWaylandSession, - isWSL, - parseTargetList, - runCommand, - safeAvailableFormats, - type RunCommand, -} from './clipboard-common'; +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; -const DEFAULT_POWERSHELL_TIMEOUT_MS = 2000; - -function hasImageViaWlPaste(run: RunCommand): boolean { - const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); - if (!list.ok) return false; - return parseTargetList(list.stdout).some((t) => isSupportedImageMimeType(t)); -} - -function hasImageViaXclip(run: RunCommand): boolean { - const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { - timeoutMs: DEFAULT_LIST_TIMEOUT_MS, - }); - if (!targets.ok) return false; - return parseTargetList(targets.stdout).some((t) => isSupportedImageMimeType(t)); -} - -function hasImageViaPowerShell(run: RunCommand): boolean { - const script = - "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); ($img -ne $null)"; - const result = run('powershell.exe', ['-NoProfile', '-Command', script], { - timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, - }); - if (!result.ok) return false; - const output = result.stdout.toString('utf-8').trim().toLowerCase(); - return output === 'true'; -} - async function hasImageViaNative(clip: ClipboardModule | null): Promise { if (clip === null) return false; @@ -58,44 +21,24 @@ export async function clipboardHasImage(options?: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; clipboard?: ClipboardModule | null; - runCommand?: RunCommand; }): Promise { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; const clip = options?.clipboard ?? clipboard; - const run = options?.runCommand ?? runCommand; if (env['TERMUX_VERSION'] !== undefined) return false; - if (platform === 'linux') { - const wayland = isWaylandSession(env); - const wsl = isWSL(env); + // The focus-driven clipboard-image hint does not probe on Linux. The probe + // would spawn wl-paste / xclip, which on Wayland perturbs seat focus and + // re-triggers the terminal's focus event, creating a focus feedback loop + // (window repeatedly gains/loses focus, IME candidate window cannot stay + // focused — see issue #1090). macOS and Windows are fine: both use the + // in-process native module, which neither spawns a subprocess nor perturbs + // focus. + // + // Image *paste* is unaffected on all platforms: it reads the clipboard + // through readClipboardMedia() on the explicit paste path, not here. + if (platform !== 'darwin' && platform !== 'win32') return false; - let xclipResult: boolean | undefined; - const xclipHasImage = (): boolean => { - xclipResult ??= hasImageViaXclip(run); - return xclipResult; - }; - - if (wayland || wsl) { - if (hasImageViaWlPaste(run)) return true; - if (xclipHasImage()) return true; - } - if (wsl && hasImageViaPowerShell(run)) return true; - if (!wayland) { - if (xclipHasImage()) return true; - if (await hasImageViaNative(clip)) return true; - } - return false; - } - - if (platform === 'darwin') { - return hasImageViaNative(clip); - } - - if (platform === 'win32') { - return hasImageViaNative(clip); - } - - return false; + return hasImageViaNative(clip); } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index e1c862899..be55e798e 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -81,17 +81,32 @@ export async function loadPluginMarketplace( configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, options.workDir, ); + const fetchImpl = options.fetchImpl ?? fetch; let raw: string; try { - raw = await readMarketplaceText(location, options.fetchImpl ?? fetch); + raw = await readMarketplaceText(location, fetchImpl); } catch (error) { const fallback = configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; if (fallback === undefined) throw error; - raw = await readMarketplaceText(fallback, options.fetchImpl ?? fetch); - return parsePluginMarketplace(raw, fallback); + raw = await readMarketplaceText(fallback, fetchImpl); + return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); } - return parsePluginMarketplace(raw, location); + return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); +} + +async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; } export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { @@ -172,12 +187,13 @@ function parseMarketplaceEntry( if (source === undefined) { throw new Error(`Plugin marketplace entry ${id} must define "source".`); } + const resolvedSource = resolveEntrySource(source, location); return { id, displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, - source: resolveEntrySource(source, location), + source: resolvedSource, tier: parseMarketplaceTier(value, id), - version: stringField(value, 'version'), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), keywords: stringArrayField(value, 'keywords'), @@ -234,6 +250,105 @@ function resolveEntrySource(source: string, location: MarketplaceLocation): stri return resolve(dirname(location.resolved), trimmed); } +/** + * Best-effort derivation of a semver version from a GitHub source URL that pins + * a specific ref. Lets a marketplace entry omit `version` when the source + * already encodes the release (for example `/releases/tag/v6.0.3`), keeping the + * source URL the single source of truth and avoiding drift between the two. + * + * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; + * bare repo URLs, branch names and commit SHAs yield `undefined`, so update + * detection degrades to "unknown" instead of comparing meaningless values. + */ +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + // Pathname shape: ///. Recognized tails: + // releases/tag/ + // tree/ + // commit/ + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + // Only bare repo URLs (//) qualify — URLs with a ref tail are + // already handled by deriveVersionFromGithubSource. + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise { + // Avoid api.github.com: its anonymous quota is shared with the user's browser + // and other tools, and a first-time lookup failing because something else + // burned the budget is unacceptable. The /releases/latest UI route 302s to + // the tag and is not part of the API quota. + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + function resolveLocalPath(input: string, workDir: string): string { if (input === '~') return homedir(); if (input.startsWith('~/')) return join(homedir(), input.slice(2)); diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts index 457b686a3..87f72696c 100644 --- a/apps/kimi-code/src/utils/usage/debug-timing.ts +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -1,7 +1,30 @@ +import { formatTokenCount } from './usage-format'; + +interface DebugTokenUsage { + readonly inputOther?: number; + readonly inputCacheRead?: number; + readonly inputCacheCreation?: number; + readonly output?: number; +} + export interface StepTimingInput { - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly usage?: { readonly output: number } | undefined; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + /** + * Split of `llmFirstTokenLatencyMs` into the client-side request-build + * portion (`llmRequestBuildMs`) and the network + API-server portion + * (`llmServerFirstTokenMs`). Both present together or not at all. + */ + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + /** + * Split of `llmStreamDurationMs` (the decode window) into server time spent + * awaiting parts (`llmServerDecodeMs`) and client time spent processing parts + * (`llmClientConsumeMs`). Both present together or not at all. + */ + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly usage?: DebugTokenUsage; } // Decode TPS is only meaningful when the output actually streamed over a @@ -17,21 +40,68 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine const streamMs = input.llmStreamDurationMs; if (latency === undefined || streamMs === undefined) return undefined; - const parts: string[] = [`TTFT: ${formatDuration(latency)}`]; + const parts: string[] = [`TTFT: ${formatTtft(input)}`]; const outputTokens = input.usage?.output; if (outputTokens !== undefined && outputTokens > 0) { if (streamMs >= MIN_STREAM_MS_FOR_TPS) { const tps = (outputTokens / (streamMs / 1000)).toFixed(1); - parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)})`); + parts.push( + `TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, + ); } else { parts.push( `${outputTokens} tokens in ${formatDuration(streamMs)} (stream too short for TPS)`, ); } } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + return `[Debug] ${parts.join(' | ')}`; } +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +// Render TTFT, splitting the latency into the network + API-server portion and +// the in-process request-build portion when the provider reported the +// boundary. Falls back to the bare total otherwise. +function formatTtft(input: StepTimingInput): string { + const total = formatDuration(input.llmFirstTokenLatencyMs ?? 0); + const build = input.llmRequestBuildMs; + const server = input.llmServerFirstTokenMs; + if (build === undefined || server === undefined) return total; + return `${total} (api ${formatDuration(server)} + client ${formatDuration(build)})`; +} + +// Render the decode-window split as a trailing clause, e.g. +// `; server 4.6s + client 0.4s`. A large client share means the host's per-part +// processing is throttling decode. Empty when the provider did not report it. +function formatDecodeSplit(input: StepTimingInput): string { + const server = input.llmServerDecodeMs; + const client = input.llmClientConsumeMs; + if (server === undefined || client === undefined) return ''; + return `; server ${formatDuration(server)} + client ${formatDuration(client)}`; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 39963536e..4518cf3e7 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -382,6 +382,7 @@ describe('kimi export', () => { exit: ((code: number) => { throw new ExitCalled(code); }) as ExportDeps['exit'], + getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }), }); await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], { diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index c82281932..73d759841 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -347,6 +347,30 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes update alias to the upgrade handler', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'update']); + + expect(upgradeCalls).toBe(1); + }); + it('registers the visible sub-commands', () => { const program = createProgram( '0.0.0', diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index cf273a89d..27f0bea57 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -398,13 +398,13 @@ describe('runShell', () => { harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { outcome: 'success' }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'unauthorized', }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'network_or_other', }); }); @@ -505,7 +505,7 @@ describe('runShell', () => { ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number) }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); @@ -551,7 +551,7 @@ describe('runShell', () => { expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { - duration_s: expect.any(Number), + duration_ms: expect.any(Number), }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); @@ -594,7 +594,7 @@ describe('runShell', () => { '1.2.3-test', ); const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; - const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1'; + const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1'; (tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl; await expect((tui as { onExit: () => Promise }).onExit()).rejects.toBeInstanceOf( diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index b465d4e4f..782ccbc92 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -9,10 +9,10 @@ */ import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import chalk, { Chalk } from 'chalk'; import { Command } from 'commander'; @@ -45,7 +45,7 @@ describe('kimi server', () => { const server = program.commands.find((c) => c.name() === 'server'); expect(server).toBeDefined(); const subs = server?.commands.map((c) => c.name()).toSorted(); - expect(subs).toEqual(['kill', 'ps', 'run']); + expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']); }); it('`server run` exposes local-only foreground options', () => { @@ -55,10 +55,13 @@ describe('kimi server', () => { ?.commands.find((c) => c.name() === 'run'); expect(run).toBeDefined(); const longs = run!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); expect(longs).toContain('--foreground'); // run defaults to NOT opening the browser → option is the positive --open expect(longs).toContain('--open'); @@ -87,7 +90,7 @@ describe('kimi server', () => { const longs = web!.options.map((o) => o.long).filter(Boolean); // web defaults to opening → the option is the negative form --no-open expect(longs).toContain('--no-open'); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); }); }); @@ -333,12 +336,50 @@ describe('`kimi server run` background start', () => { expect(parsed).toMatchObject({ logLevel: 'debug' }); }); + it('warns and uses the running server host when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + // The user asks for a public bind, but a loopback daemon is already up. + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // A clear notice that a server was already running and options were ignored. + expect(plain).toContain('A server is already running'); + expect(plain).toContain('kimi server kill'); + // The banner uses the *actual* host (loopback), not the requested 0.0.0.0 — + // so it shows a Local URL plus the "network disabled" hint, NOT real + // Network addresses (which would be misleading since nothing binds them). + expect(plain).toContain('http://127.0.0.1:58627/#token=tok'); + expect(plain).toContain('use --host to enable'); + expect(plain).not.toContain('Network: http'); + }); + it('prints a TUI-style ready panel once the daemon is up', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; await handleRunCommand( - { port: '58627' }, + { port: '58627', host: '127.0.0.1' }, { startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), openUrl: vi.fn(), @@ -357,21 +398,33 @@ describe('`kimi server run` background start', () => { ); const plain = stripAnsi(stdout); - expect(plain).toContain('╭'); - expect(plain).toContain('╰'); - expect(plain).toContain('▐█▛█▛█▌'); - expect(plain).toContain('▐█████▌'); expect(plain).toContain('Kimi server ready'); - expect(plain).toContain('URL:'); + expect(plain).toContain('Local:'); expect(plain).toContain('http://127.0.0.1:58627/'); + // Loopback bind shows a Network hint for enabling network access. expect(plain).toContain('Network:'); - expect(plain).toContain('local only'); + expect(plain).toContain('use --host to enable'); expect(plain).toContain('Logs:'); expect(plain).toContain('off'); expect(plain).toContain('Stop:'); expect(plain).toContain('kimi server kill'); + // Version sits on the title line; no separate Ready:/Version: rows and no + // startup-time metric. + expect(plain).not.toContain('Ready:'); + expect(plain).not.toContain('Version:'); + expect(plain).not.toContain(' ms'); + // No bordered panel (the token URL must print in full for copying), but + // the Kimi sprite stays next to the title. + expect(plain).not.toContain('╭'); + expect(plain).not.toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); expect(plain).not.toContain('➜'); expect(plain).not.toContain('Kimi server:'); + + // Title is above the URLs; Logs/Stop are at the bottom. + expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:')); + expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:')); }); it('uses the TUI dark palette for the ready banner', async () => { @@ -382,7 +435,7 @@ describe('`kimi server run` background start', () => { try { await handleRunCommand( - { port: '58627' }, + { port: '58627', host: '127.0.0.1' }, { startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), openUrl: vi.fn(), @@ -407,8 +460,8 @@ describe('`kimi server run` background start', () => { expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); - expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); - expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); }); }); @@ -453,7 +506,7 @@ describe('`kimi server run --foreground`', () => { const openUrl = vi.fn(); await handleRunCommand( - { port: '58627', foreground: true, open: true }, + { port: '58627', host: '127.0.0.1', foreground: true, open: true }, { startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), startServerForeground: async (options, hooks) => { @@ -524,12 +577,18 @@ function listenOnce(host: string, port: number): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); - server.listen({ host, port }, () => resolve(server)); + server.listen({ host, port }, () => { + resolve(server); + }); }); } function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())); + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); } async function allocateFreePort(host = '127.0.0.1'): Promise { @@ -564,6 +623,262 @@ async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promi throw new Error('could not allocate a run of adjacent free ports'); } +describe('--host threading (M6.2)', () => { + it('passes --host through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 }); + }); + + it('passes --host through to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' }); + }); +}); + +describe('default bind (M6.3)', () => { + it('defaults host to 127.0.0.1 and insecureNoTls to true when no flags are passed', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '127.0.0.1', insecureNoTls: true }); + }); + + it('treats a bare --host as the default LAN host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); +}); + +describe('--allowed-host threading', () => { + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/server/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); + + it('threads --allowed-host to the background daemon options', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', allowedHost: ['.example.com'] }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ allowedHosts: ['.example.com'] }); + }); +}); + +describe('lockConnectHost (M6.2 connect side)', () => { + it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + // The daemon binds 0.0.0.0 (all interfaces), but the local CLI must + // connect over loopback — 0.0.0.0 is not a connectable address. The token + // then rides on that loopback connection (covered by the M5.4 kill/ps + // Authorization tests). + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe( + '127.0.0.1', + ); + }); + + it('preserves a loopback / concrete bind host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe( + '127.0.0.1', + ); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe( + '192.168.1.5', + ); + }); + + it('falls back to 127.0.0.1 when the lock has no host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1'); + }); +}); + +describe('--insecure-no-tls threading (M6.3)', () => { + it('threads --insecure-no-tls to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true, foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('threads --insecure-no-tls to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ insecureNoTls: true }); + }); +}); + +describe('ready banner reflects the bind class (M6.3)', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('Network:'); + // Full token-bearing URLs are printed plainly (no box, no truncation) so + // they are easy to copy. + expect(raw).toContain('http://localhost:58627/#token=tok-xyz'); + expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz'); + expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-xyz'); + expect(raw).not.toContain('╭'); + }); + + it('prints the Local URL and token for a 127.0.0.1 bind', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-loop', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + // Full token-bearing URL, printed plainly for copying. + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + expect(raw).not.toContain('╭'); + }); +}); + describe('resolveDaemonPort', () => { it('returns the preferred port when it is free', async () => { const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); @@ -620,7 +935,7 @@ describe('resolveDaemonProgram', () => { it('normalizes a relative executable path against cwd outside SEA mode', async () => { const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe('/tmp/kimi-bin/kimi'); + expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe(resolve('/tmp/kimi-bin', './kimi')); }); it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { @@ -673,6 +988,109 @@ describe('spawnDaemonChild', () => { expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); expect(options?.cwd).not.toBe(process.cwd()); }); + + it('passes --host through to the daemon child args (M6.2)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0'])); + }); + + it('passes --insecure-no-tls through to the daemon child args (M6.3)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls'])); + }); + + it('passes --allowed-host through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', allowedHosts: ['.example.com'] }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--allowed-host', '.example.com'])); + }); +}); + +describe('ensureDaemon surfaces boot failures via early exit', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon'); + + // Seed the daemon log with the kind of line a failing boot writes, so we + // can assert it is surfaced to the user instead of a generic timeout. + mkdirSync(dirname(daemonLogPath()), { recursive: true }); + writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n'); + + // Fake child that exits with code 1 shortly after the 'exit' listener is + // attached — simulating a daemon that fails during boot. + const fakeChild = { + unref: vi.fn(), + once: vi.fn((event: string, cb: (...a: unknown[]) => void) => { + if (event === 'exit') { + setTimeout(() => { + cb(1, null); + }, 5); + } + return fakeChild; + }), + }; + spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess); + + const start = Date.now(); + let caught: unknown; + try { + await ensureDaemon({ port: 0 }); + } catch (error) { + caught = error; + } + const elapsed = Date.now() - start; + + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).toMatch(/exited with code 1/); + expect(message).toContain('Refusing to bind'); + // Must fail fast — nowhere near the 20s spawn timeout. + expect(elapsed).toBeLessThan(5000); + }); }); describe('createIdleShutdownHandler', () => { @@ -809,6 +1227,7 @@ function makeKillDeps(overrides: Partial = {}): { requestShutdown: async () => { state.shutdownCalls += 1; }, + resolveToken: () => undefined, signalPid: (pid, signal) => { signals.push({ pid, signal }); return true; @@ -883,5 +1302,271 @@ describe('`kimi server kill`', () => { }); }); +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from /server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), 'secret-token\n'); + expect(resolveServerToken(dir)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), ' tok \n'); + expect(resolveServerToken(dir)).toBe('tok'); + }); + + it('throws a clear error when the token file is missing', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); + }); +}); + +describe('authHeaders', () => { + it('builds a Bearer Authorization header', async () => { + const { authHeaders } = await import('#/cli/sub/server/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('`kimi server kill` carries the bearer token', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('passes the resolved token to requestShutdown', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBe('tok-123'); + }); + + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + const url = buildWebUrl('http://127.0.0.1:58627', 'abc123'); + expect(url).toBe('http://127.0.0.1:58627/#token=abc123'); + const parsed = new URL(url); + expect(parsed.hash).toBe('#token=abc123'); + // The token is client-side only: it must NOT appear in the path or query + // (which WOULD be sent to the server and logged). + expect(parsed.pathname).not.toContain('abc123'); + expect(parsed.search).not.toContain('abc123'); + }); + + it('normalizes a trailing slash', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe( + 'http://127.0.0.1:58627/#token=t', + ); + }); +}); + +describe('accessUrlLines', () => { + it('returns Local + Network lines for a wildcard bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [ + { address: '192.168.1.5', family: 'IPv4' }, + ]); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://localhost:58627/#token=tok' }, + { label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' }, + ]); + }); + + it('returns a single Local line for a loopback bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('127.0.0.1', 58627, 'tok'); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' }, + ]); + }); + + it('returns a single URL line for a specific host (no token)', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('192.168.1.5', 58627, undefined); + expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]); + }); + + it('splitTokenFragment splits off the #token= fragment', async () => { + const { splitTokenFragment } = await import('#/cli/sub/server/access-urls'); + expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']); + expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']); + }); +}); + +describe('`kimi web` / `server run --open` token fragment (M5.5)', () => { + it('opens the Web UI URL with the token fragment when a token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-xyz', + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => undefined, + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server rotate-token`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a new token to server.token and prints it', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(token.length).toBeGreaterThan(20); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(token); + }); + + it('re-prints the access links with the new token when a server is running', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live lock pointing at this (alive) process so getLiveLock() finds + // the running server and the command can re-print its links. + mkdirSync(join(dir, 'server'), { recursive: true }); + writeSync( + join(dir, 'server', 'lock'), + JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + port: 58627, + host: '127.0.0.1', + }), + ); + + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`); + // Token line sits between the note and the links. + expect(stdout.indexOf('picks up the new token')).toBeLessThan( + stdout.indexOf('New server token'), + ); + expect(stdout.indexOf('New server token')).toBeLessThan( + stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`), + ); + }); +}); + +describe('formatHostForUrl', () => { + it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => { + const { formatHostForUrl } = await import('#/cli/sub/server/networks'); + expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5'); + expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]'); + }); +}); + +describe('filterDisplayAddresses', () => { + it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => { + const { filterDisplayAddresses } = await import('#/cli/sub/server/networks'); + const out = filterDisplayAddresses([ + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '192.168.1.5', family: 'IPv4' }, + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: 'fe80::1', family: 'IPv6' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + expect(out).toEqual([ + { address: '192.168.1.5', family: 'IPv4' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + }); +}); + // Silence vi import for cases where the file is built before tests reference vi. void vi; diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index a96d1445b..0a5690566 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -828,8 +828,8 @@ describe('runUpdatePreflight', () => { const track = vi.fn(); await runUpdatePreflight('0.4.0', { ...options, track }); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - current: '0.4.0', - latest: '0.5.0', + current_version: '0.4.0', + target_version: '0.5.0', decision: 'prompt-install', source: 'npm-global', })); @@ -915,7 +915,7 @@ describe('runUpdatePreflight', () => { expect.objectContaining({ target: { version: '0.5.0' } }), ); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.5.0', + target_version: '0.5.0', rollout_bucket: expect.any(Number), rollout_delay_seconds: 0, rollout_from_manifest: true, @@ -945,7 +945,7 @@ describe('runUpdatePreflight', () => { expect.objectContaining({ target: { version: '0.7.0' } }), ); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.7.0', + target_version: '0.7.0', rollout_bucket: expect.any(Number), rollout_delay_seconds: 43_200, rollout_from_manifest: true, diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts index f17240eff..6729cb377 100644 --- a/apps/kimi-code/test/cli/version.test.ts +++ b/apps/kimi-code/test/cli/version.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -15,7 +15,7 @@ describe('cli version helpers', () => { const pkgPath = getHostPackageJsonPath(); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - expect(pkgPath.endsWith('/apps/kimi-code/package.json')).toBe(true); + expect(pkgPath.endsWith(join('apps', 'kimi-code', 'package.json'))).toBe(true); expect(getHostPackageRoot()).toBe(dirname(pkgPath)); expect(getVersion()).toBe(pkg.version); }); diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts new file mode 100644 index 000000000..8903d1626 --- /dev/null +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('uploadArchive', () => { + it('requests upload parts, PUTs each part, and completes with etags', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + expect(api.createUploadUrl).toHaveBeenCalledWith({ + feedbackId: 3, + filename: 'repo.zip', + size: 5, + sha256: 'hash', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://example.test/part1'); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(ReadableStream); + expect((init as { duplex?: string }).duplex).toBe('half'); + expect(new Headers(init.headers).get('content-length')).toBe('5'); + // Drain the stream so the underlying file handle is released. + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('uses the backend-provided part upload method', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-method-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'POST', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('aborts a stalled part PUT and does not mark upload complete', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, + { once: true }, + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 25, maxRetries: 0 }, + ); + const expectation = expect(upload).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(25); + await expectation; + expect(api.completeUpload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('retries a failed part and completes once it succeeds', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-retry-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + let attempt = 0; + const fetchMock = vi.fn(async () => { + attempt += 1; + if (attempt === 1) return new Response('server error', { status: 500 }); + return new Response('', { status: 200, headers: { ETag: '"etag-1"' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 10_000 }, + ); + await vi.advanceTimersByTimeAsync(1_000); + await upload; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); +}); + +describe('packageCodebase', () => { + it('rejects empty codebase archives instead of uploading an empty zip', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-codebase.zip'); + try { + await expect( + packageCodebase( + { + root: tmpdir(), + files: [], + fingerprint: 'empty-codebase', + usedGitIgnore: false, + }, + archivePath, + ), + ).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + + +describe('scanCodebase filtering', () => { + it('rejects when the scan signal is already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); + const controller = new AbortController(); + controller.abort(); + try { + await expect(scanCodebase(root, { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips dependency and build directories outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-no-git-')); + try { + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n'); + await writeFile(join(root, 'dist', 'bundle.js'), 'built\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(false); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files even when tracked by git', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); + try { + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, '.envrc'), 'export AWS_SECRET_ACCESS_KEY=secret\n'); + await writeFile(join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=secret\n'); + await writeFile(join(root, '.yarnrc.yml'), 'npmAuthToken: secret\n'); + await writeFile(join(root, 'id_rsa'), 'private-key\n'); + await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + const paths = scan.files.map((file) => file.path); + expect(paths).toContain('app.ts'); + expect(paths).not.toContain('.env'); + expect(paths).not.toContain('.envrc'); + expect(paths).not.toContain('.npmrc'); + expect(paths).not.toContain('.yarnrc.yml'); + expect(paths).not.toContain('id_rsa'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files by glob outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-sensitive-')); + try { + await mkdir(join(root, '.ssh')); + await writeFile(join(root, '.env.production'), 'SECRET=1\n'); + await writeFile(join(root, 'tls.pem'), 'cert\n'); + await writeFile(join(root, '.ssh', 'config'), 'Host *\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips individual files larger than the per-file limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-large-file-')); + try { + await writeFile(join(root, 'big.bin'), randomBytes(256)); + await writeFile(join(root, 'small.txt'), 'hello\n'); + + const scan = await scanCodebase(root, { limits: { maxFileSize: 128 } }); + expect(scan.files.map((file) => file.path)).toEqual(['small.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips tracked files that were deleted from the working tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-deleted-')); + try { + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + await writeFile(join(root, 'deleted.ts'), 'export const gone = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + // Remove only from the working tree; the index still lists it, so + // `git ls-files` reports a path that no longer exists on disk. + await rm(join(root, 'deleted.ts')); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when file count reaches the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-limit-')); + try { + await writeFile(join(root, 'a.txt'), 'a\n'); + await writeFile(join(root, 'b.txt'), 'b\n'); + await writeFile(join(root, 'c.txt'), 'c\n'); + + const scan = await scanCodebase(root, { limits: { maxFiles: 2 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'file-count', limit: 2 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when cumulative file size reaches the archive limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-total-size-')); + try { + await writeFile(join(root, 'a.txt'), 'a'.repeat(100)); + await writeFile(join(root, 'b.txt'), 'b'.repeat(100)); + await writeFile(join(root, 'c.txt'), 'c'.repeat(100)); + + // 250 bytes fits any two files (200) but not the third (300). + const scan = await scanCodebase(root, { limits: { maxArchiveSize: 250 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'total-size', limit: 250 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe('removeStaleFeedbackUploads', () => { + it('removes archive dirs older than the cutoff and keeps recent ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-uploads-gc-')); + try { + const staleDir = join(root, 'stale'); + const freshDir = join(root, 'fresh'); + await mkdir(staleDir); + await mkdir(freshDir); + await writeFile(join(staleDir, 'repo.zip'), 'old'); + await writeFile(join(freshDir, 'repo.zip'), 'new'); + + const now = Date.now(); + const twoDaysAgoSec = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + await utimes(staleDir, twoDaysAgoSec, twoDaysAgoSec); + + await removeStaleFeedbackUploads({ now, dir: root }); + + await expect(stat(staleDir)).rejects.toThrow(); + await expect(stat(freshDir)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('is a no-op when the cache dir does not exist', async () => { + const missing = join(tmpdir(), 'feedback-uploads-gc-missing-' + String(Date.now())); + await expect(removeStaleFeedbackUploads({ dir: missing })).resolves.toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/paths.test.ts b/apps/kimi-code/test/scripts/native/paths.test.ts index 80209f457..826ade0ba 100644 --- a/apps/kimi-code/test/scripts/native/paths.test.ts +++ b/apps/kimi-code/test/scripts/native/paths.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { @@ -18,6 +20,10 @@ import { SEA_SENTINEL_FUSE, } from '../../../scripts/native/paths.mjs'; +// paths.mjs builds every path with node:path.resolve (backslashes on Windows). +// Build expectations the same way so they match on every platform. +const p = (...segments: string[]): string => resolve(appRoot, ...segments); + describe('targetTriple', () => { it('returns platform-arch when env unset', () => { expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64'); @@ -49,27 +55,27 @@ describe('executableName', () => { describe('path helpers', () => { it('returns absolute intermediates dir under app root', () => { - expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`); + expect(nativeIntermediatesDir()).toBe(p('dist-native/intermediates')); }); it('returns absolute bin dir per target', () => { - expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`); + expect(nativeBinDir('darwin-arm64')).toBe(p('dist-native/bin/darwin-arm64')); }); it('returns absolute bin path with executable name', () => { expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( - `${appRoot}/dist-native/bin/darwin-arm64/kimi`, + p('dist-native/bin/darwin-arm64/kimi'), ); expect(nativeBinPath('win32-x64', 'win32')).toBe( - `${appRoot}/dist-native/bin/win32-x64/kimi.exe`, + p('dist-native/bin/win32-x64/kimi.exe'), ); }); it('returns intermediate artifact paths', () => { - expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.cjs`); - expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/kimi.blob`); + expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs')); + expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kimi.blob')); expect(nativeSeaConfigPath()).toBe( - `${appRoot}/dist-native/intermediates/sea-config.json`, + p('dist-native/intermediates/sea-config.json'), ); }); @@ -78,21 +84,21 @@ describe('path helpers', () => { }); it('returns native dist root', () => { - expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`); + expect(nativeDistRoot()).toBe(p('dist-native')); }); it('returns manifest dir for target', () => { expect(nativeManifestDir('darwin-arm64')).toBe( - `${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`, + p('dist-native/intermediates/native-assets/darwin-arm64'), ); }); it('returns artifacts dir', () => { - expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`); + expect(nativeArtifactsDir()).toBe(p('dist-native/artifacts')); }); it('returns smoke home', () => { - expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`); + expect(nativeSmokeHome()).toBe(p('dist-native/smoke-home')); }); it('has correct SEA sentinel fuse value', () => { diff --git a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts new file mode 100644 index 000000000..eae75a099 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPluginSlashCommands, pluginCommandName } from '#/tui/commands/plugin-commands'; + +describe('pluginCommandName', () => { + it('namespaces a command with its plugin id', () => { + expect(pluginCommandName('my-plugin', 'deploy')).toBe('my-plugin:deploy'); + }); +}); + +describe('buildPluginSlashCommands', () => { + it('namespaces commands and maps them to their bodies', () => { + const { commands, commandMap } = buildPluginSlashCommands([ + { + pluginId: 'my-plugin', + name: 'deploy', + description: 'Deploy', + body: 'Deploy $ARGUMENTS', + path: '/p/deploy.md', + }, + ]); + expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); + }); + + it('returns empty commands for no defs', () => { + const { commands, commandMap } = buildPluginSlashCommands([]); + expect(commands).toEqual([]); + expect(commandMap.size).toBe(0); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index ab54a221b..76ffb0a7b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -72,7 +72,9 @@ auto_install = false await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( session, 'Session reloaded.', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 10569be53..614553bb4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -14,6 +14,7 @@ function resolve( return resolveSlashCommandInput({ input, skillCommandMap: new Map(), + pluginCommandMap: new Map(), isStreaming: false, isCompacting: false, ...overrides, @@ -307,4 +308,33 @@ describe('slash command busy helpers', () => { expect(slashBusyMessage('new', 'streaming')).toContain('Cannot /new while streaming'); expect(slashBusyMessage('new', 'compacting')).toContain('Cannot /new while compacting'); }); + + it('resolves a namespaced plugin command to a plugin-command intent', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('/my-plugin:deploy prod', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'prod', + }); + }); + + it('resolves a nested plugin command whose name contains a slash', () => { + const pluginCommandMap = new Map([['my-plugin:frontend/component', 'body']]); + expect(resolve('/my-plugin:frontend/component spin', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'frontend/component', + pluginId: 'my-plugin', + args: 'spin', + }); + }); + + it('blocks a plugin command while streaming', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect(resolve('/my-plugin:deploy', { pluginCommandMap, isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'my-plugin:deploy', + reason: 'streaming', + }); + }); }); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 5692d1d7a..cbd6e5eec 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,7 +1,63 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; -import { webSessionUrl } from '#/tui/commands/web'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; + +const mocks = vi.hoisted(() => ({ + ensureDaemon: vi.fn(), + tryResolveServerToken: vi.fn(), + getDataDir: vi.fn(() => '/tmp/kimi-home'), + openUrl: vi.fn(), +})); + +vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureDaemon: mocks.ensureDaemon }; +}); + +vi.mock('#/cli/sub/server/shared', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken }; +}); + +vi.mock('#/utils/open-url', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, openUrl: mocks.openUrl }; +}); + +vi.mock('#/utils/paths', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getDataDir: mocks.getDataDir }; +}); + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +function makeHost() { + let mountedPanel: MountedPanel | null = null; + const host = { + session: { id: 'ses-1' }, + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(), + setExitOpenUrl: vi.fn(), + stop: vi.fn(async () => {}), + } as unknown as SlashCommandHost & { + showStatus: ReturnType; + showError: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + setExitOpenUrl: ReturnType; + stop: ReturnType; + }; + return { host, getMountedPanel: () => mountedPanel }; +} describe('web slash command', () => { it('is registered as an always-available built-in', () => { @@ -11,6 +67,60 @@ describe('web slash command', () => { }); }); +describe('handleWebCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); + mocks.ensureDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: false, + host: '127.0.0.1', + port: 58627, + }); + }); + + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); + }); + + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); +}); + describe('webSessionUrl', () => { it('deep-links to the session under the origin', () => { expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( @@ -29,4 +139,16 @@ describe('webSessionUrl', () => { 'http://127.0.0.1:58627/sessions/a%2Fb%20c', ); }); + + it('carries the bearer token in the fragment so the browser authenticates on load', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', 'tok-1')).toBe( + 'http://127.0.0.1:58627/sessions/abc123#token=tok-1', + ); + }); + + it('omits the fragment when no token is available', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', undefined)).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); }); 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 4045e3a06..6b43ab2f7 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -48,6 +48,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, diff --git a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts new file mode 100644 index 000000000..e8bea6504 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -0,0 +1,42 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; + +// MoonLoader starts a real setInterval in its constructor, so every loader +// created in these tests must be stopped to avoid leaving live timers behind. +const loaders: MoonLoader[] = []; + +function createLoader(): MoonLoader { + const ui = { requestRender() {} } as unknown as TUI; + const loader = new MoonLoader(ui, 'moon'); + loaders.push(loader); + return loader; +} + +afterEach(() => { + for (const loader of loaders) loader.stop(); + loaders.length = 0; +}); + +describe('MoonLoader', () => { + it('keeps the tip out of renderInline so it does not squeeze against the swarm progress bar', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const inline = loader.renderInline(); + expect(inline).not.toContain('Tip'); + expect(inline).not.toContain('steer'); + expect(inline.trim().length).toBeGreaterThan(0); + }); + + it('still shows the tip on its own row when width allows', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const row = loader.render(80).join('\n'); + expect(row).toContain('Tip: ctrl+s: steer mid-turn'); + }); +}); 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 a910c2481..1eb757e67 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -26,6 +26,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, 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 367a85578..ec590e252 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 @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -144,4 +145,35 @@ describe('ChoicePickerComponent', () => { picker.handleInput(' '); expect(onSelect).toHaveBeenCalledWith('a'); }); + + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', + }, + ]; + + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; + + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); + }); }); 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 c652c28a1..05a9b3bef 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 @@ -2,18 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors, lightColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -const ANSI_SGR = /\u001b\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -37,6 +39,12 @@ function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); +} + const superpowers = { id: 'superpowers', displayName: 'Superpowers', @@ -46,6 +54,8 @@ const superpowers = { skillCount: 14, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path' as const, }; @@ -90,6 +100,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', @@ -102,6 +114,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', @@ -114,6 +128,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/demo.zip', @@ -126,12 +142,28 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', })).toBe('third-party'); }); + it('treats only the official Kimi CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + // Curated and other Kimi CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); + // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); + }); + it('opens on the Installed tab with the four panel tabs', () => { const { panel } = makePanel({ installed: [superpowers] }); const out = strip(renderRaw(panel)); @@ -180,6 +212,60 @@ describe('plugins selector dialogs', () => { expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); }); + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), + }); + }); + + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + it('renders the inline plugin hint on the installed row', () => { const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; const { panel } = makePanel({ @@ -213,11 +299,18 @@ describe('plugins selector dialogs', () => { }); }); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); + }); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); // Catalog still loading (entries empty); pressing ↓ must not drive the // selection negative, or the later Enter would read entries[-1]. - panel.handleInput('\u001b[B'); // ↓ + panel.handleInput('\u001B[B'); // ↓ panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ @@ -253,6 +346,32 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); }); + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + it('shows installed · v when the installed plugin is up to date', () => { const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; const entries = [ @@ -307,6 +426,8 @@ describe('plugins selector dialogs', () => { skillCount: 1, mcpServerCount: 1, enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z', @@ -374,7 +495,7 @@ describe('plugins selector dialogs', () => { }, }); - picker.handleInput('\u001b[B'); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); expect(strip(raw)).toContain('Enter/Space select'); // The destructive option label keeps its danger styling (error + bold). @@ -384,4 +505,49 @@ describe('plugins selector dialogs', () => { expect(results).toEqual([{ kind: 'confirm' }]); }); + + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); + + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); + }); + + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); + + picker.handleInput('\r'); + + expect(results).toEqual([{ kind: 'confirm' }]); + }); }); 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 e6129ffce..a1f53b8d1 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 @@ -29,6 +29,22 @@ function providerReturning(items: AutocompleteItem[]): AutocompleteProvider { }; } +function providerRecordingForce(items: AutocompleteItem[]): { + provider: AutocompleteProvider; + calls: Array<{ force: boolean | undefined; text: string }>; +} { + const calls: Array<{ force: boolean | undefined; text: string }> = []; + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol, options) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + calls.push({ force: options?.force, text }); + return { items, prefix: text }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + return { provider, calls }; +} + describe('CustomEditor autocomplete Escape handling', () => { it('escape closes a visible slash command menu without firing app-level escape', async () => { const editor = makeEditor(); @@ -74,6 +90,29 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); +describe('CustomEditor onNonEscapeInput', () => { + it('fires for a printable key and not for a lone Escape', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('a'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + + editor.handleInput('\u001B'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); + + it('fires for control keys so they break a pending double-Esc', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('\u0003'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); +}); + describe('CustomEditor slash argument completion refresh', () => { it('reopens /add-dir directory completions after tab completion and entering slash', async () => { const editor = makeEditor(); @@ -333,6 +372,35 @@ describe('CustomEditor slash argument hint', () => { const plain = editor.render(90).map(stripAnsi).join('\n'); expect(plain).not.toContain('[list] | '); }); + + it('does not render the argument hint in bash mode', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | ']])); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | '); + }); + + it('does not highlight the slash token in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const contentLine = editor.render(90)[1] ?? ''; + const tokenIdx = contentLine.indexOf('/add-dir'); + expect(tokenIdx).toBeGreaterThan(-1); + // Prompt mode wraps `/add-dir` in a primary-colour ANSI sequence; in bash + // mode the token is plain text, so the byte right before it is a space. + expect(contentLine[tokenIdx - 1]).toBe(' '); + }); }); describe('CustomEditor slash menu description wrapping', () => { @@ -461,7 +529,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\u0016'); + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); @@ -552,3 +620,152 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onToggleTodoExpand).toHaveBeenCalledOnce(); }); }); + +describe('CustomEditor bash mode border label', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('shows "! shell mode" on the top border in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top.startsWith('╭')).toBe(true); + expect(top).toContain('! shell mode'); + expect(top.endsWith('╮')).toBe(true); + }); + + it('does not show the shell mode label in prompt mode', () => { + const editor = makeEditor(); + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top).not.toContain('! shell mode'); + }); + + it('keeps the top border at full width when the label is present', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const width = 90; + const top = stripAnsi(editor.render(width)[0] ?? ''); + expect(top).toHaveLength(width); + }); +}); + +describe('CustomEditor bash mode via paste', () => { + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; + + it('enters bash mode and strips the leading ! when !cmd is pasted into an empty prompt', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe('ls'); + expect(modes).toEqual(['bash']); + }); + + it('enters bash mode on a bare pasted ! with an empty buffer', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}!${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('does not enter bash mode when pasting !cmd into a non-empty prompt', () => { + const editor = makeEditor(); + editor.handleInput('hello'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toContain('hello'); + expect(editor.getText()).toContain('!ls'); + }); + + it('does not enter bash mode for a pasted command without a leading !', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toBe('ls'); + }); + + it('keeps the typed ! behaviour (bash mode, empty buffer)', () => { + const editor = makeEditor(); + editor.handleInput('!'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('enters bash mode on a CSI-u encoded ! keystroke (Kitty/VSCode terminals)', () => { + const editor = makeEditor(); + editor.handleInput('\u001B[33u'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); +}); + +describe('CustomEditor bash mode file completion', () => { + it('triggers file completion (force:true) for a leading / in bash mode, not the slash menu', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'auto', label: 'auto' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('triggers file completion (force:true) for an inline / in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'etc', label: 'etc' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of 'ls /') { + editor.handleInput(char); + } + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: 'ls /' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('keeps force:false (slash menu) for a leading / in prompt mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'help', label: 'help' }]); + editor.setAutocompleteProvider(provider); + // inputMode defaults to 'prompt' + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: false, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('never falls back to force:false for a slash-shaped command in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'list', label: 'list' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + // A force:false request would let pi-tui's own slash-command handling pop + // up subcommand completions for `/add-dir `. Bash mode must only ever + // request force:true path completion. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.force === true)).toBe(true); + }); +}); 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 ab0588632..5e2fa6ef3 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 @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -99,6 +99,21 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('opens @ file mention when typed in the middle of a slash command argument', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + // Cursor sits in the middle of the /goal argument text, right after a + // freshly typed `@`. The slash-argument guard must not suppress the @ + // file list here. + const line = '/goal Fix the @checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal Fix the @'.length, { + signal: ctrl(), + }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); + }); + it('still completes slash arguments at the end of an empty argument', async () => { const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal '; @@ -279,7 +294,7 @@ describe('FileMentionProvider', () => { expect(result).not.toBeNull(); expect(result!.items.map((item) => item.value)).toContain( - `@${join(extraDir, 'src', 'Additional.ts')}`, + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, ); }); @@ -298,7 +313,7 @@ describe('FileMentionProvider', () => { const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); expect(additionalResult).not.toBeNull(); expect(additionalResult!.items.map((item) => item.value)).toContain( - `@${join(extraDir, 'src', 'Additional.ts')}`, + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, ); }); @@ -312,7 +327,7 @@ describe('FileMentionProvider', () => { expect(result).not.toBeNull(); const overlapItems = result!.items.filter( - (item) => item.description === join(extraDir, 'src', 'Overlap.ts'), + (item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'), ); expect(overlapItems).toHaveLength(1); }); @@ -395,4 +410,167 @@ describe('FileMentionProvider', () => { ); expect(dir.lines[0]).toBe('hey @src/'); }); + + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); + + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); + }); + + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); + + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); + + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); + }); + + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); + + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); + + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index d137558b9..6d3145b14 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -75,4 +75,32 @@ describe('wrapWithSideBorders', () => { // first column was a space → replaced with │; last column was 'c' → kept expect(out[1]).toBe('│ abc'); }); + + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); 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 308c3aa21..8d42ca5e0 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 @@ -1,5 +1,6 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import * as cliHighlight from 'cli-highlight'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; @@ -7,6 +8,14 @@ import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; +vi.mock('cli-highlight', async () => { + const actual = await vi.importActual('cli-highlight'); + return { + ...actual, + highlight: vi.fn(actual.highlight), + }; +}); + function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -60,4 +69,60 @@ describe('AssistantMessageComponent', () => { expect(text).toContain(''); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('reuses the same Markdown child across streaming text updates', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello world'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + expect(strip(component.render(80).join('\n'))).toContain('hello world'); + }); + + it('does not recreate the Markdown child when the text is unchanged', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + }); + + it('rebuilds the Markdown child when transient changes so final render can highlight code', () => { + const component = new AssistantMessageComponent(); + const code = '```ts\nconst x = 1\n```'; + + component.updateContent(code, { transient: true }); + const streaming = (component as any).contentContainer.children[0]; + expect(streaming).toBeInstanceOf(Markdown); + + component.updateContent(code, { transient: false }); + const finalized = (component as any).contentContainer.children[0]; + expect(finalized).toBeInstanceOf(Markdown); + + expect(finalized).not.toBe(streaming); + }); + + it('skips synchronous syntax highlighting in transient markdown themes', () => { + const highlightSpy = vi.mocked(cliHighlight.highlight); + highlightSpy.mockClear(); + const streamingTheme = createMarkdownTheme({ transient: true }); + const finalTheme = createMarkdownTheme(); + const code = 'const x = 1'; + + expect(streamingTheme.highlightCode?.(code, 'typescript')).toEqual([code]); + expect(highlightSpy).not.toHaveBeenCalled(); + + finalTheme.highlightCode?.(code, 'typescript'); + expect(highlightSpy).toHaveBeenCalled(); + }); }); 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 63c60a12e..6e5e261d8 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -2,7 +2,10 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import { CronMessageComponent } from '#/tui/components/messages/cron-message'; -import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from '#/tui/components/messages/status-message'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -39,3 +42,19 @@ describe('CronMessageComponent', () => { } }); }); + +describe('StatusMessageComponent', () => { + it('strips carriage returns so a CRLF line does not render blank', () => { + // A trailing `\r` (e.g. from a CRLF server error page) is zero-width for + // the line wrapper, so padding spaces appended after it would otherwise + // overwrite the visible content. The status component strips `\r`. + const component = new StatusMessageComponent('Error: boom\r\nmore\r', 'error'); + const text = component + .render(120) + .map((line) => strip(line)) + .join('\n'); + expect(text).toContain('Error: boom'); + expect(text).toContain('more'); + expect(text).not.toContain('\r'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts new file mode 100644 index 000000000..510da06bd --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ShellRunComponent } from '#/tui/components/messages/shell-run'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellRunComponent hardening', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + // Always clear the 1s timer so it can't keep the test process alive or + // fire requestRender after the test ends. + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('caps the running buffer and never throws on huge streaming output', () => { + const c = create(); + const chunk = 'x'.repeat(50_000); + expect(() => { + for (let i = 0; i < 20; i++) c.append(chunk); + c.render(100); + }).not.toThrow(); + }); + + it('finish switches to the final view and ignores later appends', () => { + const c = create(); + c.finish('final output', '', false); + c.append('should be ignored'); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('final output'); + expect(rendered).not.toContain('should be ignored'); + }); + + it('finishBackgrounded renders the background hint', () => { + const c = create(); + c.finishBackgrounded(); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('Moved to background.'); + }); + + it('append / finish are no-ops after dispose', () => { + const c = create(); + c.dispose(); + expect(() => { + c.append('late'); + c.finish('late', '', false); + c.finishBackgrounded(); + c.render(100); + }).not.toThrow(); + }); + + it('does not throw when the render callback throws', () => { + const c = new ShellRunComponent(() => { + throw new Error('render failed'); + }); + component = c; + expect(() => { + c.append('output'); + c.render(100); + }).not.toThrow(); + }); +}); 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 cf5f8d33e..3b7e82469 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 @@ -855,9 +855,11 @@ describe('ToolCallComponent', () => { ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Read (apps/kimi-code/src/main.ts)'); + const expectedReadPath = + process.platform === 'win32' ? 'apps\\kimi-code\\src\\main.ts' : 'apps/kimi-code/src/main.ts'; + expect(out).toContain(`Used Read (${expectedReadPath})`); expect(out).not.toContain('/tmp/proj-a/apps'); - expect(component.getReadSnapshot().filePath).toBe('apps/kimi-code/src/main.ts'); + expect(component.getReadSnapshot().filePath).toBe(expectedReadPath); }); it('keeps Read paths outside the active workspace absolute', () => { 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 3d957eb5b..23ec702ae 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 @@ -89,4 +89,19 @@ describe('UserMessageComponent', () => { expect(imageLine).not.toContain('…'); expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator }); + + it('omits the sparkles bullet when an empty bullet is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const withBullet = stripAnsi(new UserMessageComponent('hello', []).render(80).join('\n')); + expect(withBullet).toContain('✨'); + expect(withBullet).toContain('hello'); + + const lines = new UserMessageComponent('$ ls', [], '').render(80).map(stripAnsi); + const contentLine = lines.find((l) => l.includes('$ ls')); + expect(contentLine).toBeDefined(); + expect(stripAnsi(lines.join('\n'))).not.toContain('✨'); + // The `$` sits at the leading column where the bullet used to be. + expect(contentLine?.startsWith('$ ls')).toBe(true); + }); }); 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 4d079a1e9..9f067b168 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 @@ -1,3 +1,5 @@ +import { pathToFileURL } from 'node:url'; + import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; @@ -70,7 +72,7 @@ describe('PlanBoxComponent', () => { it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); const top = box.render(60)[0]!; - expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); + expect(top).toContain(`${ESC}]8;;${pathToFileURL('/tmp/plan.md').href}${BEL}plan.md${ESC}]8;;${BEL}`); // After stripping OSC + CSI, visible width must respect the requested render width. expect(strip(top).length).toBeLessThanOrEqual(60); }); 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 a3644e8c2..ee6dcbb1c 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 @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; @@ -108,6 +109,43 @@ describe('TodoPanelComponent', () => { expect(out).toMatch(/ctrl\+t to expand/); }); + it('collapsed footer shows hidden status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + ...Array.from({ length: 6 }, (_, i) => ({ + title: `ip${i}`, + status: 'in_progress' as const, + })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `p${i}`, status: 'pending' as const })), + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+7 more \(3 done · 1 in progress · 3 pending\)/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer omits zero-count statuses', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+3 more \(3 done\)/); + expect(out).not.toMatch(/0 in progress/); + expect(out).not.toMatch(/0 pending/); + }); + + it('expanded footer does not include status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/all 8 items · ctrl\+t to collapse/); + expect(out).not.toMatch(/\d+ done ·/); + }); + it('renders every todo with a collapse hint when expanded', () => { const panel = new TodoPanelComponent(); panel.setTodos(many(7)); @@ -305,4 +343,41 @@ describe('selectVisibleTodos', () => { expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']); expect(hidden).toBe(2); }); + + it('returns hiddenCounts reflecting the hidden items', () => { + const todos: TodoItem[] = [ + ...Array.from({ length: 6 }, (_, i) => T(`ip${i}`, 'in_progress')), + ...Array.from({ length: 3 }, (_, i) => T(`d${i}`, 'done')), + ...Array.from({ length: 3 }, (_, i) => T(`p${i}`, 'pending')), + ]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(7); + expect(hiddenCounts).toEqual({ done: 3, in_progress: 1, pending: 3 }); + }); + + it('returns zero hiddenCounts when count <= 5', () => { + const todos: TodoItem[] = [T('a', 'done'), T('b', 'in_progress'), T('c', 'pending')]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(0); + expect(hiddenCounts).toEqual({ done: 0, in_progress: 0, pending: 0 }); + }); +}); + +describe('formatHiddenCounts', () => { + it('formats all three statuses in done / in progress / pending order', () => { + expect(formatHiddenCounts({ done: 2, in_progress: 1, pending: 3 })).toBe( + '2 done · 1 in progress · 3 pending', + ); + }); + + it('omits zero-count statuses', () => { + expect(formatHiddenCounts({ done: 5, in_progress: 0, pending: 0 })).toBe('5 done'); + expect(formatHiddenCounts({ done: 0, in_progress: 2, pending: 3 })).toBe( + '2 in progress · 3 pending', + ); + }); + + it('returns empty string when all counts are zero', () => { + expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); + }); }); 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 ca276a138..0f9a01177 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 @@ -82,4 +82,41 @@ describe('QueuePaneComponent', () => { expect(messageLine).toContain('line one line two line three'); expect(messageLine).not.toContain('\n'); }); + + it('renders bash queued items with a $ prompt to distinguish them from text', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'ls -la', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('❯ $ ls -la'); + }); + + it('omits the steer hint when every queued item is a bash command', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).not.toContain('ctrl-s to steer immediately'); + expect(output).toContain('will send after current task'); + }); + + it('keeps the steer hint when at least one queued item is steerable', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }, { text: 'focus on tests' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('ctrl-s to steer immediately'); + }); }); diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts index 435d1263b..0c69ebac9 100644 --- a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -71,6 +71,22 @@ function createFakeTUIWithConsumingFocusTracker(): FakeTUI { } as unknown as FakeTUI; } +// Drive the controller through its first clipboard observation with an empty +// clipboard. The first observation only establishes a baseline and never shows +// a hint, leaving the controller armed and ready for the next new image. +async function primeEmptyBaseline(ui: FakeTUI): Promise { + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +// Simulate the user returning to the terminal and let the debounced check fire. +async function focusReturnAndFlush(ui: FakeTUI): Promise { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + describe('ClipboardImageHintController', () => { let platformSpy: ReturnType | undefined; @@ -86,7 +102,7 @@ describe('ClipboardImageHintController', () => { vi.useRealTimers(); }); - it('shows hint when focus returns and clipboard has image', async () => { + it('does not show a hint for an image already in the clipboard at startup', async () => { vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); @@ -101,11 +117,62 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_OUT); + // Startup baseline observes the image already present; the focus check + // runs too but must stay quiet for that same image. ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('shows hint when a new image is copied during the session', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('shows hint for the first image copied after startup when startup baseline was empty', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).toBeNull(); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); @@ -135,9 +202,7 @@ describe('ClipboardImageHintController', () => { controller.stop(); }); - it('respects cooldown between hints', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - + it('does not repeat the hint for the same lingering image', async () => { const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -150,22 +215,23 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); - expect(footer.getTransientHint()).not.toBeNull(); + // Establish the baseline and show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + // The same image is still in the clipboard: focusing again must not nag. + vi.mocked(clipboardHasImage).mockClear(); footer.setTransientHint(null); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).toBeNull(); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); controller.stop(); }); - it('clears hint after 2 seconds', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - + it('shows the hint again for a new image after the clipboard is cleared', async () => { const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -178,8 +244,42 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // Establish the baseline, then show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // Clipboard cleared: the empty check re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows again. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('clears the hint after the display duration', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); await vi.advanceTimersByTimeAsync(4000); @@ -203,6 +303,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(clipboardHasImage).mockClear(); + ui.emitInput(TERMINAL_FOCUS_IN); ui.emitInput(TERMINAL_FOCUS_OUT); await vi.advanceTimersByTimeAsync(1000); @@ -214,8 +317,6 @@ describe('ClipboardImageHintController', () => { }); it('handles rapid focus churn without duplicate checks or hints', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -228,6 +329,10 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardHasImage).mockClear(); + for (let i = 0; i < 5; i++) { ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); @@ -260,7 +365,8 @@ describe('ClipboardImageHintController', () => { ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); ui.emitInput(TERMINAL_FOCUS_OUT); await vi.advanceTimersByTimeAsync(1500); @@ -291,7 +397,8 @@ describe('ClipboardImageHintController', () => { ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); controller.stop(); resolveDeferred(true); @@ -300,8 +407,6 @@ describe('ClipboardImageHintController', () => { }); it('clears a displayed hint when stopped', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -314,8 +419,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); controller.stop(); @@ -339,6 +445,7 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + // First observation only establishes the baseline and sets no hint. ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); const otherHint = 'Other hint'; @@ -351,16 +458,6 @@ describe('ClipboardImageHintController', () => { }); it('uses only the latest clipboard read result after focus churn', async () => { - const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise }> = []; - vi.mocked(clipboardHasImage).mockImplementation(() => { - let resolve: (value: boolean) => void = () => {}; - const promise = new Promise((res) => { - resolve = res; - }); - deferreds.push({ resolve, promise }); - return promise; - }); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -373,14 +470,28 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // Establish an empty baseline with a normal resolved read first. + await primeEmptyBaseline(ui); + + const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + deferreds.push({ resolve, promise }); + return promise; + }); ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(deferreds).toHaveLength(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(2); deferreds[0]!.resolve(true); await vi.advanceTimersByTimeAsync(0); @@ -394,8 +505,6 @@ describe('ClipboardImageHintController', () => { }); it('keeps the existing auto-clear timer when a re-check exits early', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -408,15 +517,14 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); // Trigger a re-check that exits early because the clipboard is now empty. vi.mocked(clipboardHasImage).mockResolvedValue(false); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await focusReturnAndFlush(ui); // The previous hint should still be visible because its auto-clear timer // was preserved through the re-check. @@ -430,8 +538,6 @@ describe('ClipboardImageHintController', () => { }); it('does not clear a matching hint owned by another caller after auto-clear', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -444,8 +550,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); const hintText = footer.getTransientHint(); expect(hintText).not.toBeNull(); @@ -459,7 +566,7 @@ describe('ClipboardImageHintController', () => { expect(footer.getTransientHint()).toBe(hintText); }); - it('does not inherit cooldown after stop and restart', async () => { + it('re-establishes the baseline after stop and restart', async () => { vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); @@ -474,26 +581,32 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + // Image already present at start: baseline only, no hint. ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(footer.getTransientHint()).not.toBeNull(); + expect(footer.getTransientHint()).toBeNull(); controller.stop(); controller.start(); - footer.setTransientHint(null); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // After restart the image is still present: baseline again, no hint. + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); - expect(footer.getTransientHint()).not.toBeNull(); + // Clipboard cleared: re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); controller.stop(); }); it('observes focus events even when another listener consumes them', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUIWithConsumingFocusTracker(); const host: ClipboardImageHintHost = { @@ -516,11 +629,17 @@ describe('ClipboardImageHintController', () => { return undefined; }); + // Baseline observation (consumed), then a new image on the next focus. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(consumedEvents).toEqual([TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); expect(footer.getTransientHint()).toMatch(/Image in clipboard/); controller.stop(); @@ -529,7 +648,6 @@ describe('ClipboardImageHintController', () => { it('shows Alt+V shortcut on Windows', async () => { platformSpy?.mockRestore(); vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); const ui = createFakeTUI(); @@ -543,8 +661,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).toMatch(/Alt\+V/); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts new file mode 100644 index 000000000..5bc96cd74 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/kimi-tui'; +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; + +interface Harness { + readonly host: EditorKeyboardHost; + readonly editor: Record unknown) | undefined>; + readonly openUndoSelector: ReturnType; + readonly cancelRunningShellCommand: ReturnType; +} + +function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { + const editor: Record unknown) | undefined> = {}; + const openUndoSelector = vi.fn(); + const cancelRunningShellCommand = vi.fn(); + const session = { cancel: vi.fn(async () => {}) }; + + const host = { + state: { + editor, + activeDialog: null, + appState: { + streamingPhase: options.streamingPhase ?? 'idle', + isCompacting: options.isCompacting ?? false, + }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + openUndoSelector, + cancelRunningShellCommand, + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + return { host, editor, openUndoSelector, cancelRunningShellCommand }; +} + +function pressEscape(editor: Harness['editor']): void { + const handler = editor['onEscape']; + if (handler === undefined) throw new Error('onEscape handler not installed'); + (handler as () => void)(); +} + +function pressNonEscape(editor: Harness['editor']): void { + const handler = editor['onNonEscapeInput']; + if (handler === undefined) throw new Error('onNonEscapeInput handler not installed'); + (handler as () => void)(); +} + +describe('EditorKeyboardController double-Esc undo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens the undo selector when Esc is pressed twice within the window while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + expect(openUndoSelector).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(openUndoSelector).toHaveBeenCalledOnce(); + }); + + it('does nothing for a single Esc while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when the second Esc arrives after the window expires', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + vi.advanceTimersByTime(DOUBLE_ESC_WINDOW_MS + 1); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when another key is pressed between the two Esc presses', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + pressNonEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger undo while streaming; Esc cancels the stream instead', () => { + const { editor, host, openUndoSelector, cancelRunningShellCommand } = createHarness({ + streamingPhase: 'waiting', + }); + + pressEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + expect(cancelRunningShellCommand).toHaveBeenCalled(); + const session = host.session as unknown as { cancel: ReturnType }; + expect(session.cancel).toHaveBeenCalled(); + }); +}); 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 a67715f2b..7c4cbcebc 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -12,6 +12,7 @@ function fakeInitialAppState(): AppState { sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + inputMode: 'prompt', swarmMode: false, thinking: false, contextUsage: 0, 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 a8a019b69..d5a0328f1 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,7 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { deleteAllKittyImages, @@ -23,6 +23,7 @@ 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 { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, @@ -30,18 +31,44 @@ import { import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; }); +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCodebase: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + +// /feedback falls back to opening GitHub Issues in a browser when not signed in +// or when submission fails — stub it out so the test suite never spawns a +// browser window. +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); + const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -61,12 +88,13 @@ interface MessageDriver { init(): Promise; handleUserInput(text: string): void; persistInputHistory(text: string): Promise; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; getCurrentSessionId(): string; } interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise; - promptFeedbackInput(): Promise; + promptFeedbackInput(): Promise; } interface ModelSelectorDriver extends MessageDriver { @@ -211,6 +239,12 @@ function makeHarness(session = makeSession(), overrides: Record resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), @@ -227,8 +261,11 @@ function makeHarness(session = makeSession(), overrides: Record logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, @@ -322,6 +359,14 @@ async function makeTempHome(): Promise { return dir; } +async function makeExportedSessionZip(content = 'session zip'): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-code-feedback-export-')); + tempDirs.push(dir); + const zipPath = join(dir, 'session.zip'); + await writeFile(zipPath, content); + return zipPath; +} + afterEach(async () => { resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { @@ -465,8 +510,9 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.track.mockClear(); await handleFeedbackCommand(feedbackDriver as any); @@ -480,6 +526,309 @@ command = "vim" }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); + }); + + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record]>; + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { @@ -498,7 +847,8 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, @@ -1024,6 +1374,49 @@ command = "vim" expect(transcript).not.toContain('Approved: Run shell command'); }); + it('removes debug timing status from undone turns', async () => { + const { driver, session } = await makeDriver(); + const previousDebug = process.env['KIMI_CODE_DEBUG']; + process.env['KIMI_CODE_DEBUG'] = '1'; + try { + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + llmFirstTokenLatencyMs: 120, + llmStreamDurationMs: 800, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('[Debug]'); + }); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('[Debug]'); + } finally { + if (previousDebug === undefined) { + delete process.env['KIMI_CODE_DEBUG']; + } else { + process.env['KIMI_CODE_DEBUG'] = previousDebug; + } + } + }); + it('undoes multiple turns when a count is provided', async () => { const { driver, session } = await makeDriver(); @@ -1241,6 +1634,149 @@ command = "vim" } }); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); + + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('does not persist bash input to input history', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).not.toHaveBeenCalled(); + }); + + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); + }); + + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('does not steer while a shell command is running', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); + }); + + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); + }); + + it('recalls a queued bash command back into bash mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; + // After a bash command is queued the editor is reset to prompt mode. + driver.state.editor.inputMode = 'prompt'; + driver.state.appState.inputMode = 'prompt'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('ls'); + expect(driver.state.editor.inputMode).toBe('bash'); + expect(driver.state.appState.inputMode).toBe('bash'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('recalls a queued prompt message in prompt mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'hello', agentId: 'main' }]; + driver.state.editor.inputMode = 'bash'; + driver.state.appState.inputMode = 'bash'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('hello'); + expect(driver.state.editor.inputMode).toBe('prompt'); + expect(driver.state.appState.inputMode).toBe('prompt'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('echoes a bash command with a $ prompt in the transcript', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + await Promise.resolve(); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('$ ls'); + expect(transcript).not.toContain('! ls'); + }); + it('renders cron fired events as distinct transcript entries', async () => { const { driver } = await makeDriver(); @@ -1326,7 +1862,7 @@ command = "vim" await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } @@ -1423,6 +1959,30 @@ command = "vim" expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancelCompaction).not.toHaveBeenCalled(); + expect(driver.state.appState.isCompacting).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + it('dispatches the next queued message after compaction is cancelled', async () => { vi.useFakeTimers(); try { @@ -3068,15 +3628,46 @@ command = "vim" expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('installs from a positional source on /plugins install', async () => { + it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith( + resolve('/tmp/proj-a', './plugins/kimi-datasource'), + ); + }); + }); + + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); it('loads a local plugin marketplace file and installs from it', async () => { @@ -3091,7 +3682,7 @@ command = "vim" tier: 'official', displayName: 'Kimi Datasource', description: 'Datasource plugin', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -3114,12 +3705,14 @@ command = "vim" panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { @@ -3127,6 +3720,147 @@ command = "vim" }); }); + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); + }); + + it('restores the panel when a third-party marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(panel); + }); + }); + it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); const { driver } = await makeDriver(session); 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 696024480..93f8a8b83 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -14,6 +14,7 @@ import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { quoteShellArg } from '#/utils/shell-quote'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -891,17 +892,12 @@ describe('KimiTUI startup', () => { expect(resumeSession).not.toHaveBeenCalled(); expect(driver.state.activeDialog).toBeNull(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); expect(transcript).toContain('Current session is in a different working directory.'); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); expect(transcript).toContain('Command copied to clipboard'); }); @@ -934,13 +930,10 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); }); it('exits after picking another cwd from the startup picker', async () => { @@ -974,9 +967,8 @@ describe('KimiTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); expect(stop).toHaveBeenCalledOnce(); expect(stop).toHaveBeenCalledWith(0); }); @@ -1277,6 +1269,7 @@ describe('KimiTUI startup', () => { }); expect(harness.track).toHaveBeenCalledWith('login', { provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: false, }); }); @@ -1310,6 +1303,7 @@ describe('KimiTUI startup', () => { ); expect(harness.track).toHaveBeenCalledWith('login', { provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: true, }); }); @@ -1663,6 +1657,16 @@ describe('KimiTUI startup', () => { ).toBe(true); }); + // writeBannerDisplayState runs after renderBanner; on Windows the atomic + // write can lag behind the render, so wait for the state to land before + // asserting it. + await vi.waitFor( + async () => { + const state = await readBannerDisplayState(); + expect(state.shown['once-banner']?.lastShownAt).toBeDefined(); + }, + { timeout: 5000 }, + ); await expect(readBannerDisplayState()).resolves.toMatchObject({ version: 1, shown: { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..7ffd6fdde 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -230,7 +230,7 @@ function makeHarness(initialSession: Session) { login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } @@ -307,6 +307,24 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: 'pre</bash-stdout>post', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('prepost'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( diff --git a/apps/kimi-code/test/tui/render-memo.bench.ts b/apps/kimi-code/test/tui/render-memo.bench.ts new file mode 100644 index 000000000..2bfd246f0 --- /dev/null +++ b/apps/kimi-code/test/tui/render-memo.bench.ts @@ -0,0 +1,115 @@ +/** + * Benchmark for the message-component render cache (Phase 1 + 1.5). + * + * Measures the cost of re-rendering a long transcript when *nothing* has + * changed — the common steady-state frame. With the render cache enabled + * ("cached (warm)") every message returns its previously computed lines, and + * the GutterContainer returns its cached concatenation, so the cost is roughly + * O(number of messages). With it disabled ("uncached") every message rebuilds + * its output (Markdown, Text, truncation) and the container rebuilds the full + * line array, which is O(total rendered lines) and dominates CPU as the + * transcript grows. + * + * Run: + * pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/render-memo.bench.ts + */ + +import { bench, describe } from 'vitest'; + +import type { Component } from '@earendil-works/pi-tui'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { ThinkingComponent } from '#/tui/components/messages/thinking'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { setRenderCacheEnabled } from '#/tui/utils/render-cache'; + +const WIDTH = 100; +const TRANSCRIPT_TURNS = 200; +const GUTTER = 2; + +const USER_TEXT = + 'Can you refactor the streaming renderer so that finalized assistant messages stop being re-rendered on every frame? Please keep the diff minimal and avoid touching the engine.'; + +const ASSISTANT_TEXT = [ + 'Here is a summary of the change:', + '', + '- cache the rendered lines per message component', + '- invalidate the cache when content, theme, or width changes', + '- keep the diff renderer untouched', + '', + '```ts', + 'render(width: number): string[] {', + ' if (this.cache && this.cache.width === width) return this.cache.lines;', + ' const lines = this.compute(width);', + ' this.cache = { width, lines };', + ' return lines;', + '}', + '```', + '', + 'This keeps the steady-state frame cheap while preserving correctness.', +].join('\n'); + +const THINKING_TEXT = [ + 'Let me reason through the invalidation paths carefully.', + 'The cache must be cleared on content changes, theme switches, and width changes.', + 'Width changes already trigger a full repaint, so they fall out naturally.', + 'Theme switches flow through invalidate(), so that is the hook to clear the cache.', + 'Streaming updates go through updateContent/setText, which already short-circuit when unchanged.', +].join('\n'); + +function buildMessages(turns: number): Component[] { + const components: Component[] = []; + for (let i = 0; i < turns; i++) { + components.push(new UserMessageComponent(`[${i}] ${USER_TEXT}`)); + + const assistant = new AssistantMessageComponent(); + assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`); + components.push(assistant); + + components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized')); + } + return components; +} + +function buildGutter(turns: number): GutterContainer { + const gutter = new GutterContainer(GUTTER, GUTTER); + for (const message of buildMessages(turns)) gutter.addChild(message); + return gutter; +} + +describe('render memo — flat child render', () => { + const messages = buildMessages(TRANSCRIPT_TURNS); + + // Warm up: populate every component's cache so the "cached" case measures + // steady-state cache hits rather than first-render cost. + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + for (const message of messages) message.render(WIDTH); + }); +}); + +describe('render memo — via GutterContainer', () => { + const gutter = buildGutter(TRANSCRIPT_TURNS); + + setRenderCacheEnabled(true); + gutter.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + gutter.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + gutter.render(WIDTH); + }); +}); 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 a7cbfe0df..2a3880e3d 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -144,6 +144,24 @@ describe('TaskOutputViewer — scrolling', () => { expect(out).not.toContain('line-001'); }); + it('Ctrl+D scrolls a page down', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('\u0004'); // Ctrl+D + const out = strip(viewer.render(120).join('\n')); + // Same page size as PageDown: body has 8 viewable rows, page = 7 lines. + expect(out).toContain('line-008'); + expect(out).not.toContain('line-001'); + }); + + it('Ctrl+U scrolls a page up', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('G'); // jump to bottom first + viewer.handleInput('\u0015'); // Ctrl+U + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-036'); + expect(out).not.toContain('line-050'); + }); + it('G jumps to the bottom', () => { const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); viewer.handleInput('G'); diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts new file mode 100644 index 000000000..e7a724b43 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ESC = '\u001B'; +const BEL = '\u0007'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('sanitizeShellOutput', () => { + it('leaves plain text untouched', () => { + expect(sanitizeShellOutput('hello\nworld')).toBe('hello\nworld'); + }); + + it('strips SGR colour sequences', () => { + expect(sanitizeShellOutput(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + expect(sanitizeShellOutput(`${ESC}[1;32mbold green${ESC}[0m`)).toBe('bold green'); + }); + + it('strips CSI private modes (alt screen, cursor visibility)', () => { + expect(sanitizeShellOutput(`${ESC}[?1049h${ESC}[?25l`)).toBe(''); + expect(sanitizeShellOutput(`before${ESC}[?2004hafter`)).toBe('beforeafter'); + }); + + it('strips clear-screen and cursor-movement sequences', () => { + expect(sanitizeShellOutput(`${ESC}[2J${ESC}[Hhello`)).toBe('hello'); + expect(sanitizeShellOutput(`${ESC}[10;5Hhi`)).toBe('hi'); + }); + + it('strips OSC window titles', () => { + expect(sanitizeShellOutput(`${ESC}]0;my title${BEL}text`)).toBe('text'); + }); + + it('strips OSC 8 hyperlinks but keeps the link text', () => { + const link = `${ESC}]8;;https://example.com${ESC}\\click here${ESC}]8;;${ESC}\\`; + expect(sanitizeShellOutput(link)).toBe('click here'); + }); + + it('strips carriage returns (spinner redraw)', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); + }); + + it('strips backspace, bell and NUL', () => { + expect(sanitizeShellOutput(`a\u0008b${BEL}c\u0000d`)).toBe('abcd'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeShellOutput('a\nb\tc')).toBe('a\nb\tc'); + }); + + it('strips single-char ESC commands (reset, save/restore cursor)', () => { + expect(sanitizeShellOutput(`${ESC}c${ESC}7${ESC}8text`)).toBe('text'); + }); + + it('never throws and returns "" for non-string input', () => { + expect(sanitizeShellOutput(undefined as unknown as string)).toBe(''); + expect(sanitizeShellOutput(null as unknown as string)).toBe(''); + expect(sanitizeShellOutput(42 as unknown as string)).toBe(''); + }); + + it('handles huge input without throwing', () => { + const huge = `${ESC}[31m${'x'.repeat(2_000_000)}\r${ESC}[0m`; + expect(() => sanitizeShellOutput(huge)).not.toThrow(); + }); + + it('cleans a realistic TUI/dev-server burst down to printable text', () => { + const messy = + `${ESC}[?1049h${ESC}[?25l${ESC}[2J${ESC}[H` + + `${ESC}[1m${ESC}[32mVITE${ESC}[0m ready in 120ms\r\n` + + `${ESC}]0;dev server${BEL}` + + ` Local: http://localhost:5173/`; + const result = sanitizeShellOutput(messy); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('VITE ready in 120ms'); + expect(result).toContain('Local: http://localhost:5173/'); + }); +}); + +describe('formatBashOutputForDisplay', () => { + it('shows "(no output)" when both streams are empty', () => { + expect(stripTheme(formatBashOutputForDisplay('', ''))).toBe('(no output)'); + }); + + it('strips control sequences from stdout before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay(`${ESC}[?1049h${ESC}[31mhi${ESC}[0m\r`, '')); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('hi'); + }); + + it('strips control sequences from stderr before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay('', `err${BEL}\r`, true)); + expect(result).not.toContain(ESC); + expect(result).not.toContain(BEL); + expect(result).not.toContain('\r'); + expect(result).toContain('err'); + }); + + it('never throws on malformed / non-string input', () => { + expect(() => + formatBashOutputForDisplay(undefined as unknown as string, null as unknown as string), + ).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts new file mode 100644 index 000000000..4fbc23fec --- /dev/null +++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TranscriptEntry } from '#/tui/types'; +import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; + +let seq = 0; +function makeEntry( + turnId: string | undefined, + kind: TranscriptEntry['kind'] = 'assistant', +): TranscriptEntry { + return { id: String(++seq), kind, turnId, renderMode: 'markdown', content: '' }; +} +function tool(turnId: string): TranscriptEntry { + return makeEntry(turnId, 'tool_call'); +} +function msg(turnId: string | undefined): TranscriptEntry { + return makeEntry(turnId, 'assistant'); +} + +describe('groupTurns', () => { + it('groups consecutive entries with the same turnId', () => { + const turns = groupTurns([msg('a'), tool('a'), msg('b')]); + expect(turns.map((t) => t.turnId)).toEqual(['a', 'b']); + expect(turns[0]!.entries).toHaveLength(2); + expect(turns[1]!.entries).toHaveLength(1); + }); + + it('attaches leading undefined turnId entries to the following turn', () => { + // A user message (undefined turnId) followed by its response should be one turn. + const turns = groupTurns([msg(undefined), tool('1'), msg('1')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('1'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('attaches multiple consecutive undefined entries to the following turn', () => { + const turns = groupTurns([msg(undefined), msg(undefined), msg('a')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('makes trailing undefined entries their own turn', () => { + const turns = groupTurns([msg('a'), msg(undefined)]); + expect(turns).toHaveLength(2); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[1]!.turnId).toBeUndefined(); + expect(turns[1]!.entries).toHaveLength(1); + }); +}); + +describe('turnsToTrim', () => { + it('returns empty when turn count is within maxTurns', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 5, 1).size).toBe(0); + }); + + it('does not trim within the hysteresis band', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 2, 1).size).toBe(0); // 3 <= 2 + 1 + }); + + it('trims oldest turns first', () => { + const entries = [msg('a'), msg('b'), msg('c'), msg('d')]; // 4 turns + const turns = groupTurns(entries); + const removed = turnsToTrim(turns, 2, 0); + expect(removed.has(entries[0]!)).toBe(true); + expect(removed.has(entries[1]!)).toBe(true); + expect(removed.has(entries[2]!)).toBe(false); + expect(removed.has(entries[3]!)).toBe(false); + }); + + it('never trims the most recent turn', () => { + // A single turn is never removed, even if it is huge. + const entries = Array.from({ length: 200 }, () => tool('solo')); + const turns = groupTurns(entries); // 1 turn + const removed = turnsToTrim(turns, 2, 0); + expect(removed.size).toBe(0); + }); +}); + +describe('readEnvInt', () => { + const KEY = 'KIMI_CODE_TUI_TEST_INT'; + afterEach(() => { + delete process.env[KEY]; + }); + + it('returns fallback when unset', () => { + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('reads a valid integer', () => { + process.env[KEY] = '42'; + expect(readEnvInt(KEY, 7)).toBe(42); + }); + + it('accepts 0', () => { + process.env[KEY] = '0'; + expect(readEnvInt(KEY, 7)).toBe(0); + }); + + it('falls back on negative', () => { + process.env[KEY] = '-1'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on non-integer', () => { + process.env[KEY] = 'abc'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on empty/whitespace', () => { + process.env[KEY] = ' '; + expect(readEnvInt(KEY, 7)).toBe(7); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 000000000..c6aba57a4 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { runCommandAsync } from '#/utils/clipboard/clipboard-common'; + +describe('runCommandAsync', () => { + it('resolves with stdout for a successful command', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']); + expect(result.ok).toBe(true); + expect(result.stdout.toString('utf-8')).toBe('hello'); + }); + + it('resolves ok:false for a non-zero exit', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']); + expect(result.ok).toBe(false); + }); + + it('does not block when the command exceeds the timeout', async () => { + const timeoutMs = 100; + const start = Date.now(); + // The child would idle for 30s if left running; runCommandAsync must kill + // it and resolve well before that so a wedged helper cannot freeze launch. + const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + timeoutMs, + }); + const elapsed = Date.now() - start; + + expect(result.ok).toBe(false); + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts index 5aeb0f137..94e841101 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -25,10 +25,8 @@ describe('clipboardHasImage', () => { it('returns false on macOS when native clipboard reports no image', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); - const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip, runCommand }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); expect(result).toBe(false); - expect(runCommand).not.toHaveBeenCalledWith('osascript', expect.anything(), expect.anything()); }); it('returns false on macOS when native clipboard throws', async () => { @@ -51,156 +49,29 @@ describe('clipboardHasImage', () => { expect(clip.hasImage).not.toHaveBeenCalled(); }); - it('detects image on Wayland via wl-paste list-types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'wl-paste' && args[0] === '--list-types') { - return { stdout: Buffer.from('text/plain\nimage/png\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' }, runCommand }); - expect(result).toBe(true); - }); - - it('returns false on Wayland when target list contains unsupported MIME types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'wl-paste' && args[0] === '--list-types') { - return { stdout: Buffer.from('text/plain\nimage/bmp\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + // The focus-driven hint must not probe the clipboard on Linux: spawning + // wl-paste / xclip on Wayland perturbs seat focus and re-triggers the + // terminal focus event, creating a focus feedback loop (issue #1090). + it('returns false on Linux without reading the clipboard', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); const result = await clipboardHasImage({ platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' }, - runCommand, clipboard: clip, }); expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); }); - it('falls back to xclip on Wayland when wl-paste reports no image', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'wl-paste' && args[0] === '--list-types') { - return { stdout: Buffer.from('text/plain\n'), ok: true }; - } - if (command === 'xclip' && args.includes('TARGETS')) { - return { stdout: Buffer.from('TARGETS\nimage/png\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ - platform: 'linux', - env: { WAYLAND_DISPLAY: 'wayland-1' }, - runCommand, - }); - expect(result).toBe(true); - expect(runCommand).toHaveBeenCalledWith('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], expect.anything()); - }); - - it('detects image on X11 via xclip TARGETS', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'xclip' && args.includes('TARGETS')) { - return { stdout: Buffer.from('TARGETS\nimage/jpeg\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand }); - expect(result).toBe(true); - }); - - it('returns false on X11 when target list contains unsupported MIME types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'xclip' && args.includes('TARGETS')) { - return { stdout: Buffer.from('TARGETS\nimage/tiff\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip }); - expect(result).toBe(false); - }); - - it('returns false on X11 when target list is empty', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { - if (command === 'xclip' && args.includes('TARGETS')) { - return { stdout: Buffer.from('TARGETS\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip }); - expect(result).toBe(false); - }); - - it('falls back to native hasImage on Linux X11 when xclip fails', async () => { + it('returns true on Windows when native clipboard reports an image', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); - const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); expect(result).toBe(true); }); - it('returns false on Linux X11 when xclip and native both fail', async () => { - const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); - const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand }); - expect(result).toBe(false); - }); - - it('detects WSL via WSL_DISTRO_NAME and checks PowerShell', async () => { - const runCommand = vi.fn((command: string) => { - if (command === 'powershell.exe') { - return { stdout: Buffer.from('True\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ - platform: 'linux', - env: { WSL_DISTRO_NAME: 'Ubuntu' }, - runCommand, - }); - expect(result).toBe(true); - }); - - it('detects WSL via WSLENV and checks PowerShell', async () => { - const runCommand = vi.fn((command: string) => { - if (command === 'powershell.exe') { - return { stdout: Buffer.from('True\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ - platform: 'linux', - env: { WSLENV: 'WT_SESSION' }, - runCommand, - }); - expect(result).toBe(true); - }); - - it('returns false on Linux when runCommand fails for all fallbacks', async () => { - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); - const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip }); - expect(result).toBe(false); - }); - - it('detects image on Windows via native clipboard', async () => { - const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); - const result = await clipboardHasImage({ platform: 'win32', clipboard: clip, runCommand }); - expect(result).toBe(true); - expect(runCommand).not.toHaveBeenCalledWith('powershell.exe', expect.anything(), expect.anything()); - }); - it('returns false on Windows when native clipboard reports no image', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn((command: string) => { - if (command === 'powershell.exe') { - return { stdout: Buffer.from('True\n'), ok: true }; - } - return { stdout: Buffer.alloc(0), ok: false }; - }); - const result = await clipboardHasImage({ platform: 'win32', clipboard: clip, runCommand }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); expect(result).toBe(false); }); }); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d3577bc12..9c220b868 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -125,9 +125,22 @@ describe('loadPluginMarketplace', () => { }); it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; + }) as unknown as typeof fetch; const marketplace = await loadPluginMarketplace({ workDir: REPO_ROOT, source: join(REPO_ROOT, 'plugins/marketplace.json'), + fetchImpl, }); expect(marketplace.plugins).toContainEqual( @@ -135,7 +148,8 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', + version: '6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -197,7 +211,7 @@ describe('loadPluginMarketplace', () => { expect(marketplace.plugins).toContainEqual( expect.objectContaining({ id: 'superpowers', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', }), ); } finally { @@ -221,6 +235,153 @@ describe('loadPluginMarketplace', () => { })).rejects.toThrow(/fetch failed/); }); + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); + }); + + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); + }); + + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + }); + it('accepts legacy marketplace type aliases as normal plugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index be871f3c1..353b10ee5 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,38 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('formats input tokens and cache read/write counts', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 700, + inputCacheRead: 1200, + inputCacheCreation: 100, + output: 200, + }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100', + ); + }); + + it('omits cache write count when it is zero', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 1000, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 200, + }, + }); + expect(result).toContain('tokens in 1.0k'); + expect(result).toContain('cache read 0 (0%)'); + expect(result).not.toContain('/ write 0'); + }); + it('omits TPS when the streamed window is too short to measure', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1200, @@ -57,6 +89,52 @@ describe('formatStepDebugTiming', () => { expect(result).toContain('900ms'); }); + it('splits TTFT into api-server and client portions when both are present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 2500, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 2400, + llmRequestBuildMs: 100, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 2.5s (api 2.4s + client 100ms) | TPS: 40.0 tok/s (200 tokens in 5.0s)', + ); + }); + + it('falls back to the bare TTFT when only one split component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 700, + usage: { output: 0 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms'); + }); + + it('appends the decode wait/consume split to the TPS clause', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + llmClientConsumeMs: 400, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)', + ); + }); + + it('omits the decode split when only one component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + usage: { output: 200 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); + }); + it('formats durations at or above 1s as seconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1500, diff --git a/apps/kimi-web/CHANGELOG.md b/apps/kimi-web/CHANGELOG.md new file mode 100644 index 000000000..0f53dc5c4 --- /dev/null +++ b/apps/kimi-web/CHANGELOG.md @@ -0,0 +1,7 @@ +# @moonshot-ai/kimi-web + +## 0.1.2 + +### Patch Changes + +- [#1085](https://github.com/MoonshotAI/kimi-code/pull/1085) [`f1fad72`](https://github.com/MoonshotAI/kimi-code/commit/f1fad7222ccd3f66c1cae6c5b9c009230227cd2f) - Fix stuttery streaming in the web chat by coalescing rapid token updates into a single render per frame. diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html index 46f389cd8..40e54a320 100644 --- a/apps/kimi-web/index.html +++ b/apps/kimi-web/index.html @@ -3,7 +3,7 @@ - + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index c3a262ada..0c9349592 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-web", - "version": "0.1.1", + "version": "0.1.2", "private": true, "license": "MIT", "type": "module", @@ -16,8 +16,9 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "markstream-vue": "1.0.3", - "shiki": "^4.2.0", + "katex": "^0.17.0", + "markstream-vue": "^1.0.4", + "shiki": "^4.3.0", "stream-markdown": "^0.0.16", "vue": "^3.5.35", "vue-i18n": "^11.4.5" diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 176910afa..8b43e4936 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -1,6 +1,6 @@ + + + + diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index b3ec0f0b4..adf31d559 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -3,7 +3,7 @@ The old workspace rail and workspace tabs have been removed; workspace switching, folding and renaming all live in the group header. -->
{{ formatDuration(turn.durationMs) }} @@ -655,6 +702,13 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
{{ turn.skillActivation.args }}
+
+
+ + /{{ turn.pluginCommand.pluginId }}:{{ turn.pluginCommand.commandName }} +
+
{{ turn.pluginCommand.args }}
+
@@ -664,11 +718,11 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
- +
- + @@ -1020,13 +1074,33 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): } .u-edit span { line-height: 1; } .u-edit:hover { opacity: 1; color: var(--blue); background: var(--hover); } +/* Copy button — icon-only, shares the undo button's muted→hover style. */ +.u-copy { + display: inline-flex; + align-items: center; + padding: 2px 5px; + background: none; + border: none; + border-radius: 5px; + color: var(--muted); + font: inherit; + font-size: calc(var(--ui-font-size) - 3px); + line-height: 1; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.12s, color 0.12s, background-color 0.12s; + min-height: 22px; + box-sizing: border-box; +} +.u-copy svg { display: block; flex: none; } +.u-copy:hover { opacity: 1; color: var(--blue); background: var(--hover); } /* Custom tooltip for the undo button: appears faster than the native title tooltip and avoids duplicating the browser's long default delay. */ -.u-edit[data-tooltip] { +.u-meta [data-tooltip] { position: relative; } -.u-edit[data-tooltip]::after, -.u-edit[data-tooltip]::before { +.u-meta [data-tooltip]::after, +.u-meta [data-tooltip]::before { position: absolute; left: 50%; transform: translateX(-50%); @@ -1037,7 +1111,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): transition-delay: 0s; z-index: 100; } -.u-edit[data-tooltip]::after { +.u-meta [data-tooltip]::after { content: attr(data-tooltip); bottom: calc(100% + 6px); padding: 4px 8px; @@ -1048,17 +1122,17 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): border-radius: 5px; white-space: nowrap; } -.u-edit[data-tooltip]::before { +.u-meta [data-tooltip]::before { content: ''; bottom: calc(100% + 2px); border-width: 4px; border-style: solid; border-color: var(--ink) transparent transparent transparent; } -.u-edit[data-tooltip]:hover::after, -.u-edit[data-tooltip]:hover::before, -.u-edit[data-tooltip]:focus-visible::after, -.u-edit[data-tooltip]:focus-visible::before { +.u-meta [data-tooltip]:hover::after, +.u-meta [data-tooltip]:hover::before, +.u-meta [data-tooltip]:focus-visible::after, +.u-meta [data-tooltip]:focus-visible::before { opacity: 1; visibility: visible; transition-delay: 0.25s; diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 075a7ef24..8b6503471 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -1,6 +1,6 @@