mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
Merge remote-tracking branch 'origin/main' into kimi-code-v2
This commit is contained in:
commit
075262a49d
616 changed files with 32110 additions and 5543 deletions
|
|
@ -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 <name>, 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".
|
||||
|
|
|
|||
|
|
@ -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 <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 <host> to allow an extra host.
|
||||
```
|
||||
|
||||
Before (literal, wordy):
|
||||
|
||||
```markdown
|
||||
- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host <host>` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。
|
||||
```
|
||||
|
||||
After (concise, idiomatic):
|
||||
|
||||
```markdown
|
||||
- `kimi web` 新增 `--allowed-host <host>` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
Review:
|
||||
|
|
|
|||
7
.changeset/add-gui-store-api.md
Normal file
7
.changeset/add-gui-store-api.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/double-esc-undo.md
Normal file
5
.changeset/double-esc-undo.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/fix-at-mention-slash-arg.md
Normal file
5
.changeset/fix-at-mention-slash-arg.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix @ file mentions not opening when typed inside a slash command argument.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix MCP server working directories when sessions are hosted by the web server.
|
||||
5
.changeset/fix-shell-mode-slash-completion.md
Normal file
5
.changeset/fix-shell-mode-slash-completion.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show file path completions when typing `/` in shell mode (`!`).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix truncated skill descriptions missing an ellipsis in the model's skill listing.
|
||||
6
.changeset/fix-web-duplicate-workspaces.md
Normal file
6
.changeset/fix-web-duplicate-workspaces.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/plugin-commands.md
Normal file
5
.changeset/plugin-commands.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Plugins can now provide slash commands via a `commands` field in their manifest, registered as `<plugin>:<command>` and invoked with `$ARGUMENTS` expansion.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
5
.changeset/ttft-client-server-split.md
Normal file
5
.changeset/ttft-client-server-split.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/web-telemetry-toggle.md
Normal file
5
.changeset/web-telemetry-toggle.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Create missing parent directories automatically when writing a file.
|
||||
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -13,9 +13,9 @@ coverage/
|
|||
.conductor
|
||||
.kimi-stash-dir
|
||||
plugins/cdn/
|
||||
superpowers
|
||||
.worktrees/
|
||||
.kimi-code/local.toml
|
||||
.kimi-sandbox/
|
||||
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@
|
|||
"node_modules/",
|
||||
"apps/*/scripts/",
|
||||
"docs/smoke-archive/",
|
||||
"plugins/curated/superpowers/",
|
||||
"*.generated.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
|
|
@ -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=<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) }];
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
* detached process after this returns.
|
||||
*/
|
||||
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
|
||||
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<E
|
|||
if (existing) {
|
||||
const origin = serverOrigin(lockConnectHost(existing), existing.port);
|
||||
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: true,
|
||||
host: existing.host ?? DEFAULT_SERVER_HOST,
|
||||
port: existing.port,
|
||||
};
|
||||
}
|
||||
// Live pid but not responding (wedged or mid-boot failure). Fall through
|
||||
// and spawn: if it is truly wedged our child loses the lock race and we
|
||||
|
|
@ -260,14 +311,34 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
}
|
||||
|
||||
// 2. No reusable daemon — pick a free port and spawn one detached.
|
||||
const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred);
|
||||
spawnDaemonChild({
|
||||
const port = await resolveDaemonPort(host, preferred);
|
||||
const child = spawnDaemonChild({
|
||||
host,
|
||||
port,
|
||||
logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
allowedHosts: options.allowedHosts,
|
||||
idleGraceMs: options.idleGraceMs,
|
||||
});
|
||||
|
||||
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
|
||||
// a config error, or a lost lock race with no other daemon to fall back to)
|
||||
// surfaces the real error immediately instead of waiting out the full spawn
|
||||
// timeout. The exit code/signal plus a tail of the daemon log is what tells
|
||||
// the operator *why* it failed.
|
||||
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
child.once('exit', (code, signal) => {
|
||||
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<E
|
|||
if (live) {
|
||||
const origin = serverOrigin(lockConnectHost(live), live.port);
|
||||
if (await isServerHealthy(origin, 500)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: false,
|
||||
host: live.host ?? DEFAULT_SERVER_HOST,
|
||||
port: live.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (childExit !== undefined && !live) {
|
||||
// Our child exited and no other live daemon holds the lock to fall back
|
||||
// to — this is a real boot failure, not a lost race.
|
||||
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` +
|
||||
`Check the log for details: ${daemonLogPath()}`,
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
|
||||
formatLogTail(daemonLogPath()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatDaemonBootFailure(
|
||||
exit: { code: number | null; signal: NodeJS.Signals | null },
|
||||
logPath: string,
|
||||
): string {
|
||||
const reason =
|
||||
exit.signal === null
|
||||
? `exited with code ${String(exit.code)}`
|
||||
: `was terminated by signal ${exit.signal}`;
|
||||
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
|
||||
}
|
||||
|
||||
function formatLogTail(logPath: string): string {
|
||||
const tail = tailFile(logPath, 30);
|
||||
if (tail.length === 0) {
|
||||
return `Check the log for details: ${logPath}`;
|
||||
}
|
||||
return `Last log lines (${logPath}):\n${tail}`;
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, maxLines: number): string {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.length > 0);
|
||||
return lines.slice(-maxLines).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
requestShutdown(origin: string, token: string | undefined): Promise<void>;
|
||||
/** 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<void>;
|
||||
|
|
@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
|
|||
|
||||
// 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<void> {
|
||||
export async function requestShutdownViaApi(
|
||||
origin: string,
|
||||
token: string | undefined,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
|
|||
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<void> {
|
|||
const DEFAULT_KILL_DEPS: KillCommandDeps = {
|
||||
getLiveLock,
|
||||
requestShutdown: requestShutdownViaApi,
|
||||
resolveToken: () => tryResolveServerToken(getDataDir()),
|
||||
signalPid,
|
||||
pidAlive,
|
||||
sleep: (ms) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Enumerate this machine's non-loopback network interface addresses, used to
|
||||
* print `Network: http://<addr>:<port>/` 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<string>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
process.stdout.write(formatTable(connections));
|
||||
}
|
||||
|
||||
async function fetchConnections(origin: string): Promise<ConnectionInfo[]> {
|
||||
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
|
||||
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) {
|
||||
|
|
|
|||
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `kimi server rotate-token` — generate a new persistent server token.
|
||||
*
|
||||
* Rewrites `<KIMI_CODE_HOME>/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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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<never>;
|
||||
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<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Web UI URL, carrying the bearer token in the URL fragment.
|
||||
*
|
||||
* The token rides in `#token=<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 <host> for a specific host. The bearer token is printed at startup.`,
|
||||
)
|
||||
.option(
|
||||
'--allowed-host <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 <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<EnsureDaemonResult> {
|
||||
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 `<homeDir>/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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persistent bearer token for the server.
|
||||
*
|
||||
* The server writes `<homeDir>/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 <token>`. `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 <token>` header bag for `fetch`. */
|
||||
export function authHeaders(token: string): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
72
apps/kimi-code/src/feedback/archive.ts
Normal file
72
apps/kimi-code/src/feedback/archive.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal file
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal file
|
|
@ -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<string> = 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<string> = new Set([
|
||||
'.ssh',
|
||||
'.gnupg',
|
||||
'.aws',
|
||||
'.kube',
|
||||
'.docker',
|
||||
]);
|
||||
|
||||
const SENSITIVE_FILE_NAMES: ReadonlySet<string> = 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<string> = 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;
|
||||
}
|
||||
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './packager';
|
||||
export * from './scanner';
|
||||
export * from './types';
|
||||
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal file
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal file
|
|
@ -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<FeedbackArchive> {
|
||||
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<FeedbackArchive> {
|
||||
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<void>((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');
|
||||
}
|
||||
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal file
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal file
|
|
@ -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<FeedbackCodebaseScanResult> {
|
||||
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<boolean> {
|
||||
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<CollectedFiles> {
|
||||
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<CollectedFiles> {
|
||||
const files: FeedbackCodebaseFile[] = [];
|
||||
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
|
||||
let stopped = false;
|
||||
let totalSize = 0;
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
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<FeedbackCodebaseFile | null> {
|
||||
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('/');
|
||||
}
|
||||
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal file
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal file
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<FeedbackArchive>,
|
||||
): Promise<boolean> {
|
||||
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<FeedbackArchive> {
|
||||
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<string | undefined> {
|
||||
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<FeedbackCodebaseScanResult | undefined> {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
208
apps/kimi-code/src/feedback/upload.ts
Normal file
208
apps/kimi-code/src/feedback/upload.ts
Normal file
|
|
@ -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<CreateFeedbackUploadUrlResult>;
|
||||
completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<CompletedUploadPart[]> {
|
||||
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<void> {
|
||||
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<CompletedUploadPart> {
|
||||
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<CompletedUploadPart> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
|
|||
}
|
||||
host.track('login', {
|
||||
provider: DEFAULT_OAUTH_PROVIDER_NAME,
|
||||
method: 'oauth',
|
||||
already_logged_in: alreadyLoggedIn,
|
||||
});
|
||||
if (alreadyLoggedIn) {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,14 @@ export interface SlashCommandHost {
|
|||
showSessionPicker(): Promise<void>;
|
||||
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<string, string>;
|
||||
readonly pluginCommandMap: Map<string, string>;
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<voi
|
|||
return;
|
||||
}
|
||||
|
||||
const content = await promptFeedbackInput(host);
|
||||
if (content === undefined) {
|
||||
// Stage 1: collect the free-form feedback text.
|
||||
const input = await promptFeedbackInput(host);
|
||||
if (input === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 2: ask whether to attach diagnostics (logs / codebase).
|
||||
const level = await promptFeedbackAttachment(host);
|
||||
if (level === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = withFeedbackVersionPrefix(host.state.appState.version);
|
||||
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
|
||||
const res = await host.harness.auth.submitFeedback({
|
||||
content,
|
||||
content: input.value,
|
||||
sessionId: host.state.appState.sessionId,
|
||||
version: withFeedbackVersionPrefix(host.state.appState.version),
|
||||
version,
|
||||
os: `${osType()} ${osRelease()}`,
|
||||
model: host.state.appState.model.length > 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
27
apps/kimi-code/src/tui/commands/plugin-commands.ts
Normal file
27
apps/kimi-code/src/tui/commands/plugin-commands.ts
Normal file
|
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export function pluginCommandName(pluginId: string, name: string): string {
|
||||
return `${pluginId}:${name}`;
|
||||
}
|
||||
|
||||
export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands {
|
||||
const commandMap = new Map<string, string>();
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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 <local-path-or-zip-url>');
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<voi
|
|||
async function installPluginFromSource(
|
||||
host: SlashCommandHost,
|
||||
source: string,
|
||||
options?: {
|
||||
readonly successNotice?: 'marketplace';
|
||||
},
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -57,16 +57,58 @@ export function promptLogoutProviderSelection(
|
|||
});
|
||||
}
|
||||
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> {
|
||||
export interface FeedbackPromptResult {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
|
||||
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<FeedbackAttachmentLevel | undefined> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
|
|||
const session = host.session;
|
||||
|
||||
if (session !== undefined) {
|
||||
await session.reloadSession();
|
||||
await session.reloadSession({ forcePluginSessionStartReminder: true });
|
||||
await host.reloadCurrentSessionView(session, 'Session reloaded.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
readonly pluginCommandMap: ReadonlyMap<string, string>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<TranscriptEntry['pluginCommandData']>): 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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const MAX_VISIBLE = 5;
|
|||
export interface VisibleTodos {
|
||||
readonly rows: readonly TodoItem[];
|
||||
readonly hidden: number;
|
||||
readonly hiddenCounts: Record<TodoStatus, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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<TodoStatus, number> = { 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<TodoStatus, number>): string {
|
||||
return STATUS_LABELS.filter(({ status }) => counts[status] > 0)
|
||||
.map(({ status, label }) => `${counts[status]} ${label}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'))];
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
apps/kimi-code/src/tui/components/messages/plugin-command.ts
Normal file
58
apps/kimi-code/src/tui/components/messages/plugin-command.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
134
apps/kimi-code/src/tui/components/messages/shell-run.ts
Normal file
134
apps/kimi-code/src/tui/components/messages/shell-run.ts
Normal file
|
|
@ -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<typeof setInterval> | 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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal file
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal file
|
|
@ -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(', ')}`)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface AuthFlowHost {
|
|||
fetchSessions(): Promise<void>;
|
||||
updateTerminalTitle(): void;
|
||||
refreshSkillCommands(session?: SkillListSession): Promise<void>;
|
||||
refreshPluginCommands(session?: Session): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
|
|
@ -107,6 +109,7 @@ export class AuthFlowController {
|
|||
sessionTitle: null,
|
||||
});
|
||||
await this.host.refreshSkillCommands();
|
||||
await this.host.refreshPluginCommands();
|
||||
}
|
||||
|
||||
async refreshConfigAfterLogin(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | undefined;
|
||||
private clearHintTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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<void>;
|
||||
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<typeof setTimeout> } | 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set();
|
||||
|
||||
renderedSkillActivationIds: Set<string> = new Set();
|
||||
renderedPluginCommandActivationIds: Set<string> = new Set();
|
||||
renderedMcpServerStatusKeys: Map<string, string> = new Map();
|
||||
mcpServerStatusSpinners: Map<string, MoonLoader> = new Map();
|
||||
mcpServers: Map<string, McpServerStatusSnapshot> = 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({
|
||||
|
|
|
|||
|
|
@ -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<AppState>): 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]*?)</${tag}>`).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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
private pluginCommands: readonly KimiSlashCommand[] = [];
|
||||
readonly pluginCommandMap = new Map<string, string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface ReplayRenderContext {
|
|||
toolCalls: Map<string, ToolCallBlockData>;
|
||||
completedToolCallIds: Set<string>;
|
||||
skillActivationIds: Set<string>;
|
||||
pluginCommandActivationIds: Set<string>;
|
||||
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<string, BackgroundAgentMetadata>;
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<KimiConfig>;
|
||||
removeProvider(providerId: string): Promise<KimiConfig>;
|
||||
|
|
@ -24,542 +19,25 @@ export interface RefreshProviderHost {
|
|||
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof fetchCustomRegistry>>;
|
||||
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<string>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
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<string> {
|
||||
const keys = new Set<string>();
|
||||
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<string> {
|
||||
const keys = new Set<string>();
|
||||
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<string>, newIds: Set<string>): { 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>,
|
||||
): 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<string>,
|
||||
): 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<string> {
|
||||
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<string>,
|
||||
): Record<string, ModelAlias> {
|
||||
const preserved: Record<string, ModelAlias> = {};
|
||||
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<string, ModelAlias>): 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<RefreshResult> {
|
||||
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<string>;
|
||||
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<string>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal file
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
71
apps/kimi-code/src/tui/utils/shell-output.ts
Normal file
71
apps/kimi-code/src/tui/utils/shell-output.ts
Normal file
|
|
@ -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 [ <params> <intermediates> <final> — 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 ] … <BEL> or ESC ] … ESC \ — window titles and OSC 8 hyperlinks.
|
||||
const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g;
|
||||
// ESC <char> (and ESC <intermediate> <char>) — 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)';
|
||||
}
|
||||
}
|
||||
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal file
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal file
|
|
@ -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<TranscriptEntry> {
|
||||
const toRemove = new Set<TranscriptEntry>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<typeof setTimeout>;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PluginMarketplace> {
|
||||
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: /<owner>/<repo>/<tail...>. Recognized tails:
|
||||
// releases/tag/<tag>
|
||||
// tree/<ref>
|
||||
// commit/<sha>
|
||||
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<string | undefined> {
|
||||
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 (/<owner>/<repo>) 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<string | undefined> {
|
||||
// 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));
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue