diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 71df61bfb..b5fb0fd50 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -11,20 +11,23 @@ description: Use when generating changesets in the kimi-code repository, includi All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`. +`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below. + ## Core Rules 1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed. -2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package. -3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed. -4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`. +2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter. +3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text. +4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`. + - **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. 6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. ## Workflow -1. List the packages that were actually changed. +1. List the changed packages and check whether each one is ignored by `.changeset/config.json`. 2. Choose a bump level for each package. -3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`. +3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset. 4. Create a short kebab-case file under `.changeset/`. 5. Split unrelated changes into separate changesets; keep one logical change in one file. @@ -43,10 +46,12 @@ Format: | Level | When to use | |---|---| -| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades | -| `minor` | New backwards-compatible features or capabilities | +| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) | +| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode | | `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar | +When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before. + ### Major Rule Never write `major` on your own. @@ -56,31 +61,72 @@ If you believe a change qualifies as major, stop first, explain why, and ask the ## Wording Rules - Changelog entries **must be written in English**. -- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors. + - Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.` + - CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.` + - Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.` + - Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo , and writes the result to the transcript...` - User-facing CLI wording should only be used when CLI users can perceive the change. - Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. - Do not mention file names, class names, function names, PR numbers, or commit hashes. - Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`. - Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. +## When You Are Unsure About a Change + +Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording. + +1. Finish the changeset for the parts that are clear. +2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately. +3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail. + ## Common Examples An internal package fixes a bug visible to CLI users: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- 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 --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- @@ -97,12 +143,83 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` +## Web app changes + +`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. + +- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`). +- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets. +- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. + +Web-only fix: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the chat not scrolling to the bottom after sending a message. +``` + +Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add the server-hosted web UI, including chat layout and session list behaviors. +``` + +Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.` + +## `@moonshot-ai/pi-tui` changes + +`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui. + +- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset. +- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording. + +pi-tui-only change: + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Export the package manifest so the bundled binary can locate its native assets. +``` + +pi-tui change that is also visible in the CLI (two separate changesets): + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. +``` + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the transcript jumping to the top when scrolling up through history during streaming output. +``` + ## 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". - The changelog entry is in Chinese. - The wording claims more than the diff actually did. - The CLI wording mentions internal package names, class names, or PR numbers. - The entry includes real internal identifiers instead of neutral placeholders. +- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. +- A web app change entry is missing the `web: ` prefix. +- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value). diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md new file mode 100644 index 000000000..adde7c1b3 --- /dev/null +++ b/.agents/skills/pre-changelog/SKILL.md @@ -0,0 +1,67 @@ +--- +name: pre-changelog +description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files. +--- + +# Pre-Changelog + +Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing. + +This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ. + +## Workflow + +### 1. Locate the release PR + +```bash +gh pr list --state open --search "ci: release packages in:title" \ + --json number,title,url,headRefName,baseRefName +``` + +Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as ``. If none is open, nothing to preview — stop. + +### 2. Read the pre-generated CLI changelog block + +changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff: + +```bash +gh api repos/MoonshotAI/kimi-code/pulls//files \ + --jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch' +``` + +Take the added lines (`+`) from the top `## ` down to (but not including) the next `## `. That is the version block to preview. + +If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview. + +### 3. Render the Chinese preview (reuse `sync-changelog`) + +Process the version block exactly as `sync-changelog` does for the docs site, but only in memory: + +- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints. +- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed. +- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments). +- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. + +If an upstream entry is not in English, flag it and stop (changeset entries must be English). + +### 4. Output + +Print the preview directly. Use `(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file. + +``` +发版 PR: + +## (预览) + +### 新功能 +- ... + +### 修复 +- ... +``` + +## Rules + +- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything. +- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies. +- If the release PR has no CLI changelog diff, report it and stop. diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 7dd35ad7c..25e542d4b 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-changelog -description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md. +description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch. --- # Sync Changelog @@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site. -After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese. +After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR. ## When To Use @@ -41,39 +41,65 @@ Before editing, confirm: - The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag. - The top of `apps/kimi-code/CHANGELOG.md` is that new version. -- The current branch is clean, or you are on a dedicated docs-sync branch. If any condition is not true, stop and confirm with the user. +Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1. + ## Workflow -### 1. Find The Version Range +### 1. Prepare Branch + +Start from an up-to-date default branch: ```bash -# Upstream versions -rg '^## ' apps/kimi-code/CHANGELOG.md | head -20 +git fetch origin +git checkout main +git pull --ff-only origin main +``` -# Latest version already synced into the English docs page +Before creating the branch, peek at the version range so the branch name matches the newest version being synced: + +```bash +rg '^## ' apps/kimi-code/CHANGELOG.md | head -5 rg '^## ' docs/en/release-notes/changelog.md | head -5 ``` +Name the branch after the newest upstream version that is not yet in the English docs page: + +```text +docs/changelog-sync- +``` + +Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`. + +```bash +git checkout -b docs/changelog-sync- +``` + +If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it. + +### 2. Find The Version Range + +Use the same version lists from step 1. Confirm: + - First sync: copy all upstream version blocks into the English page. - Incremental sync: copy every upstream version block above the latest version already present in the English page. Use upstream order: newest version first. -### 2. Strip Decorations And Extract Entry Text +### 3. Strip Decorations And Extract Entry Text Upstream entries look like this: ```markdown -- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ... +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ... ``` -Keep: +Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep: - Version headings such as `## 0.2.0`. -- Only the body text of each entry, after the PR/hash decoration. +- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed. Remove: @@ -81,26 +107,49 @@ Remove: - Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. - PR links such as `[#317](...)`. - Commit hash links such as ``[`2f51db4`](...)``. +- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled. -After stripping, each entry should be only: +After stripping, each entry is `- `. + +Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages: + +- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here. +- Drop provider / wire-format implementation mechanics (XML markers like ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique"). + +Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. + +Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI: ```markdown -- +- web: ``` +An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-only ` (the commit hash is the one stripped above). `gen-changesets` writes this prefix for web changes, so it is usually already present in upstream — preserve it when it is there, and add it when a web entry lacks it. When a commit touches both web and non-web code, use `web:` only if the user-facing change described by the entry is in the web UI. Keep the `web:` prefix on the Chinese page too — it is a scope marker, not translated text. + Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs. Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning. -### 3. Classify Entries +### 4. Merge, Deduplicate, And Classify Entries + +Before classifying, merge related entries and drop redundant ones from the user-facing changelog: + +- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing. +- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples: + - "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output." + - "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation." + - Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`. + - **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets. +- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide. The docs changelog uses five section types: | English section | Chinese section | Meaning | |---|---|---| | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | @@ -114,6 +163,8 @@ Classification process: Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish. +Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing. + Keyword hints: - **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` @@ -125,18 +176,19 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: 1. Put the most valuable, obvious, and larger changes first. 2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup. -3. If entries have similar value, preserve upstream order. +3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users). +4. If entries have similar value, preserve upstream order. Do not reword or exaggerate entries just to make them look more important; only reorder existing entries. -### 4. Write The English Page +### 5. Write The English Page Never change the English page header: @@ -177,7 +229,7 @@ Example: - Update the native release workflow to use current GitHub artifact actions. ``` -### 5. Translate The Increment Into Chinese +### 6. Translate The Increment Into Chinese After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. @@ -205,7 +257,54 @@ 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. -### 6. Verify +#### 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. +- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets. +- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. +- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. + - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` + - Better: `缓存已渲染消息行,提升长对话下终端的响应速度。` +- **Be specific, not vague**. Prefer concrete actions over abstract quality words. + - Bad: `加固默认系统提示词和内置工具描述。` + - Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。` +- **Name concrete files or config keys when it helps clarity**. + - Bad: `插件现在可以在其清单中声明 hooks。` + - Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。` +- **Include required argument placeholders in CLI options**. + - Bad: `--allowed-host` + - Better: `--allowed-host ` +- **Keep usage hints to one short clause**. + - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` + - Better: `例如 kimi web --allowed-host example.com。` +- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is. +- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes. + +Example — translating a feature entry: + +English source: + +```markdown +- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. +``` + +Before (literal, wordy): + +```markdown +- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host ` 以允许额外的 host。例如 `kimi web --allowed-host example.com`。 +``` + +After (concise, idiomatic): + +```markdown +- `kimi web` 新增 `--allowed-host ` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`。 +``` + +### 7. Verify Review: @@ -221,6 +320,7 @@ Check: - Each section has the same number of entries on both pages. - Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries. - PR links and commit hashes were stripped. +- No `Thanks ...!` credit remains (remove it every time). - Real internal identifiers were replaced with neutral placeholders. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -231,7 +331,36 @@ Then run the docs build: pnpm --filter docs run build ``` -### 7. Commit +### 8. Human Review Checkpoint + +After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as: + +- **Review first** — show the diff and wait for the user to finish checking. +- **Skip review, commit and open PR** — proceed directly to steps 9 and 10. + +If the user chooses review: + +1. Show the uncommitted diff: + + ```bash + git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md + ``` + +2. Summarize synced versions, section counts, and anything that needed manual classification. +3. Tell the user to reply when they are done reviewing, or to ask for edits. +4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed. + +If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint. + +### 9. Commit + +Only run this step when the user skipped review or confirmed review is complete. + +Stage only the changelog docs files: + +```bash +git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md +``` Use a neutral docs-sync commit message: @@ -241,12 +370,66 @@ docs(changelog): sync from apps/kimi-code/CHANGELOG.md Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle. +### 10. Push And Open PR + +Run immediately after step 9. + +Push the branch: + +```bash +git push -u origin HEAD +``` + +Create the PR with `gh pr create`. Title follows Conventional Commits: + +```text +docs(changelog): sync from apps/kimi-code/CHANGELOG.md +``` + +Fill in `.github/pull_request_template.md`. For changelog sync PRs: + +- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required). +- **Problem**: the docs-site changelog is behind the published CLI release(s). +- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`). +- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow. + +Example body: + +```markdown +## Related Issue + +N/A — post-release docs maintenance + +## Problem + +The docs-site changelog has not yet been synced for `` after the npm release. + +## What changed + +- Synced `` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md` +- Translated the new English increment into `docs/zh/release-notes/changelog.md` +- Verified with `pnpm --filter docs run build` + +## Checklist + +- [x] I have read the CONTRIBUTING document. +- [x] I have linked a related issue, or explained the problem above. +- [ ] I have added tests that prove my feature works. (N/A — docs-only sync) +- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle) +- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync) +``` + +Return the PR URL to the user when done. + ## Rules - The English docs changelog is the source of truth. - Never edit upstream `apps/kimi-code/CHANGELOG.md`. - Do not backfill unreleased `.changeset/*.md` drafts into the docs site. +- Prefix web UI entries with `web: ` (when the upstream commit touches `apps/kimi-web/`), and keep the prefix on both the English and Chinese pages. - If upstream wording is wrong, leave upstream alone and fix it in a future changeset. +- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`. +- Wait for the human review checkpoint before committing, pushing, or opening a PR. ## Common Mistakes @@ -254,6 +437,10 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter |---|---| | Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source | | Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form | +| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) | +| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone | +| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value | | Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | | Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | | Editing upstream changelog text | Do not edit upstream | @@ -268,8 +455,11 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter | Putting everything under Other for convenience | Classify what can be classified first | | Translating tool names, command names, or config keys | Keep them as written | | Creating a changeset for docs sync | Do not create one | +| Committing or pushing directly on `main` | Create `docs/changelog-sync-`, commit there, then open a PR | +| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint | | Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` | | Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `()` in Chinese) taken from the published tag | +| Forgetting or translating the `web:` prefix on web UI entries | Prefix web UI entries (commit touches `apps/kimi-web/`) with `web: ` on both pages; keep the prefix as-is when translating | ## Stop Signals @@ -279,3 +469,5 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter - English and Chinese versions, entry counts, or section sets do not match. - A section is empty. - A Chinese term is uncertain and `docs/AGENTS.md` does not answer it. +- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale. +- The user asked to review but has not yet confirmed review is complete. diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md index be3885a31..3952b84b5 100644 --- a/.agents/skills/write-tui/SKILL.md +++ b/.agents/skills/write-tui/SKILL.md @@ -68,6 +68,8 @@ Themes are managed centrally under `src/tui/theme/`: - `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`. - `index.ts` / `detect.ts` — theme type and auto/dark/light resolution. +> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/kimi-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`). + Apply / switch flow: - UI entry: `ThemeSelectorComponent` → `handleThemeCommand` → `applyThemeChoice`. diff --git a/.changeset/README.md b/.changeset/README.md index aed0865b2..42457c132 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -15,11 +15,17 @@ Current publishable packages: All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`: +- `@moonshot-ai/acp-adapter` - `@moonshot-ai/agent-core` +- `@moonshot-ai/kaos` - `@moonshot-ai/kimi-code-oauth` - `@moonshot-ai/kimi-telemetry` -- `@moonshot-ai/kaos` +- `@moonshot-ai/kimi-web` - `@moonshot-ai/kosong` +- `@moonshot-ai/migration-legacy` +- `@moonshot-ai/protocol` +- `@moonshot-ai/server` +- `@moonshot-ai/server-e2e` - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` @@ -39,6 +45,7 @@ Example scenarios: | SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` | | Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` | | Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed | +| Bundled official plugin change under `plugins/` (e.g. `kimi-datasource`) | No changeset — the plugin is versioned via its own `kimi.plugin.json` / `plugins/marketplace.json` and shipped through the marketplace CDN, not the npm package | ## Prerequisite: NPM Trusted Publishing (OIDC) @@ -138,6 +145,7 @@ The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, buil ## Notes - Every PR that affects publishable-package behavior or public API should include a corresponding changeset. +- Changes under `plugins/` (the bundled official plugins such as `kimi-datasource`) do **not** need a changeset: each plugin carries its own version in `kimi.plugin.json` and `plugins/marketplace.json` and is distributed via the marketplace CDN, separately from the `@moonshot-ai/kimi-code` npm package. - Changeset files must be committed to the repository — release PRs are only triggered after they're merged. - Release PRs require human review and merge; they will not publish automatically. - Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`. diff --git a/.changeset/config.json b/.changeset/config.json index af6143de9..2320f45e3 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }], + "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }], "commit": false, "fixed": [], "linked": [], @@ -7,6 +7,7 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ + "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", "@moonshot-ai/vis-web" diff --git a/.changeset/hide-web-system-asides.md b/.changeset/hide-web-system-asides.md new file mode 100644 index 000000000..d8a520a41 --- /dev/null +++ b/.changeset/hide-web-system-asides.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Hide the internal image-compression note so it no longer renders as user message text. diff --git a/.changeset/retry-fault-tolerance.md b/.changeset/retry-fault-tolerance.md new file mode 100644 index 000000000..8be58774c --- /dev/null +++ b/.changeset/retry-fault-tolerance.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b3726523e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Enforce LF line endings in the working tree on every platform so that +# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows +# and POSIX. Without this, Git for Windows' default `core.autocrlf=true` +# checks text files out as CRLF, which shifts token-count snapshots. +* text=auto eol=lf + +# Binary assets — never normalize line endings. +*.gif binary +*.ico binary +*.png binary diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index bc190ed89..7d0983503 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -85,6 +85,13 @@ jobs: node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" + - name: Build Kimi web assets + # The SEA blob step embeds apps/kimi-code/dist-web; build the web app + # and stage its assets before producing the native executable. + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + - name: Build native executable (release profile, macOS signed) if: runner.os == 'macOS' && inputs.sign-macos run: pnpm --filter @moonshot-ai/kimi-code run build:native:release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ceac2ad..4611d7f5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,45 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run test + # pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test` + # does not execute it; it needs its own job. + test-pi-tui: + runs-on: ubuntu-latest + + 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 + - run: pnpm --filter @moonshot-ai/pi-tui 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 @@ -82,3 +121,9 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck kimi-web (vue-tsc) + run: pnpm --filter @moonshot-ai/kimi-web run typecheck + - name: Typecheck vis-server + run: pnpm --filter @moonshot-ai/vis-server run typecheck + - name: Typecheck vis-web + run: pnpm --filter @moonshot-ai/vis-web run typecheck diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 000000000..6c993662b --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,170 @@ +name: desktop-build + +# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux. +# Each runner builds the matching-platform SEA backend first, then packages it +# with electron-builder. +# +# macOS is signed with a Developer ID certificate + notarized (so it opens on +# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is +# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1. +# +# Triggered two ways: +# - workflow_dispatch: manual ad-hoc builds from the Actions tab. +# - workflow_call: called by release.yml to attach installers to a release. +on: + workflow_dispatch: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: true + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 5 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + workflow_call: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: false + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 7 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + secrets: + APPLE_CERTIFICATE_P12: + required: false + APPLE_CERTIFICATE_PASSWORD: + required: false + APPLE_NOTARIZATION_KEY_P8: + required: false + APPLE_NOTARIZATION_KEY_ID: + required: false + APPLE_NOTARIZATION_ISSUER_ID: + required: false + +permissions: + contents: read + +jobs: + desktop: + name: Desktop installer (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: darwin-arm64 + - os: macos-15-intel + target: darwin-x64 + - os: windows-2025-vs2026 + target: win32-x64 + - os: ubuntu-24.04 + target: linux-x64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Kimi web assets + # The SEA blob embeds apps/kimi-code/dist-web; build the web app and + # stage its assets before producing the native executable. + # KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle + # (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the + # desktop sets this flag, so the CLI `kimi web` stays banner-free. + env: + KIMI_WEB_DESKTOP: '1' + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + + - name: Build native executable (local profile) + # The Electron app signs the SEA itself (electron-builder, inside-out), + # so the native build stays unsigned here. + run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea + + - name: Setup macOS keychain (Developer ID) + if: runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-setup + with: + certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Prepare CSC_NAME for electron-builder (macOS) + if: runner.os == 'macOS' && inputs.sign-macos + shell: bash + run: | + # electron-builder rejects the "Developer ID Application: " prefix in + # CSC_NAME; strip it so the certificate matches by team name + ID. + name="${APPLE_SIGNING_IDENTITY}" + name="${name#Developer ID Application: }" + echo "CSC_NAME=$name" >> "$GITHUB_ENV" + + - name: Prepare notarization API key (macOS) + if: runner.os == 'macOS' && inputs.sign-macos + shell: bash + env: + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + run: | + set -euo pipefail + key_path="$RUNNER_TEMP/notary-AuthKey.p8" + printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" + echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV" + + - name: Build & package desktop app + shell: bash + env: + # macOS signing is driven by env: when sign-macos, electron-builder + # signs with the keychain's Developer ID and notarizes via the notary + # API key; otherwise it builds unsigned. + CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }} + KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: pnpm --filter @moonshot-ai/kimi-desktop run dist + + - name: Cleanup macOS keychain + if: always() && runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-cleanup + + - name: Upload installers + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} + retention-days: ${{ inputs.retention-days }} + path: | + apps/kimi-desktop/dist-app/*.dmg + apps/kimi-desktop/dist-app/*.zip + apps/kimi-desktop/dist-app/*.exe + apps/kimi-desktop/dist-app/*.AppImage + apps/kimi-desktop/dist-app/*.deb + if-no-files-found: ignore diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 86de76975..fcce360b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,6 +36,9 @@ jobs: - name: Build package dependencies run: pnpm run build:packages + - name: Build Kimi web assets + run: pnpm --filter @moonshot-ai/kimi-web run build + - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f74dc9d9..e5c4c6d37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -97,6 +97,22 @@ jobs: APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + desktop-artifacts: + name: Desktop release artifact + needs: release + if: needs.release.outputs.kimi_native_release == 'true' + uses: ./.github/workflows/desktop-build.yml + with: + upload-artifact-prefix: kimi-desktop + retention-days: 7 + sign-macos: true + secrets: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + publish-native-assets: name: Publish native release assets needs: diff --git a/.gitignore b/.gitignore index 3fdbd2d60..203492e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +dist-web/ +dist-single/ dist-native/ .tmp-api-extractor/ coverage/ @@ -11,4 +13,27 @@ coverage/ .conductor .kimi-stash-dir plugins/cdn/ -superpowers +.worktrees/ +.kimi-code/local.toml +.kimi-sandbox/ + +Dockerfile +docker-compose.yml +.dockerignore + +docs/superpowers/ +reports/ +.superpowers/ +/plan/ + +# Agent scratch / throwaway files - do not commit +.tmp/ +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md +*-designs.html +*-design.html +*-mockup.html +*-demo.html +*-demos.html diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..003359f31 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,7 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/AGENTS.md b/AGENTS.md index ffe93c6aa..f8cdf7c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,16 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). +- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. +- `packages/server`: the Kimi Code server. Hosts `agent-core` sessions and exposes them over REST + WebSocket (`/api/v1`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. See `packages/server/AGENTS.md`. +- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. ## Environment Requirements @@ -32,9 +35,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Monorepo Workspace Maintenance - `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists. -- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.** +- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.** + - `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen. - Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset. - Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched. +- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions. ## General Coding Rules @@ -72,3 +77,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. - When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. - Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 66d6a21a6..901b7a6d2 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -1,2 +1,11 @@ # Copied from packages/kimi-core at build time agents/ + +# Generated at build time by scripts/build-vis-asset.mjs. +# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub +# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh +# clone (before any build has produced the `.ts`). +src/generated/vis-web-asset.ts + +# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs +native/ diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 09be44f9c..859d87410 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,909 @@ # @moonshot-ai/kimi-code +## 0.23.4 + +### Patch Changes + +- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. + +- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists. + +- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. + +- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session. + +- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography. + +- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression. + +- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser. + +- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability. + +- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent. + +- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling. + +## 0.23.3 + +### Patch Changes + +- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account. + +## 0.23.2 + +### Patch Changes + +- [#1489](https://github.com/MoonshotAI/kimi-code/pull/1489) [`2206d21`](https://github.com/MoonshotAI/kimi-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it. + +- [#1477](https://github.com/MoonshotAI/kimi-code/pull/1477) [`150206a`](https://github.com/MoonshotAI/kimi-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage. + +- [#1483](https://github.com/MoonshotAI/kimi-code/pull/1483) [`f30781b`](https://github.com/MoonshotAI/kimi-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `kimi -p` runs exiting with code 0 when a turn fails. + +- [#1466](https://github.com/MoonshotAI/kimi-code/pull/1466) [`063bce2`](https://github.com/MoonshotAI/kimi-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs. + +- [#1474](https://github.com/MoonshotAI/kimi-code/pull/1474) [`11c6a37`](https://github.com/MoonshotAI/kimi-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. + +- [#1476](https://github.com/MoonshotAI/kimi-code/pull/1476) [`d1a964f`](https://github.com/MoonshotAI/kimi-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates. + +- [#1481](https://github.com/MoonshotAI/kimi-code/pull/1481) [`1317000`](https://github.com/MoonshotAI/kimi-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates. + +- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. + +- [#1467](https://github.com/MoonshotAI/kimi-code/pull/1467) [`ee38545`](https://github.com/MoonshotAI/kimi-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders. + +- [#1471](https://github.com/MoonshotAI/kimi-code/pull/1471) [`9b76e5b`](https://github.com/MoonshotAI/kimi-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. + +- [#1491](https://github.com/MoonshotAI/kimi-code/pull/1491) [`0cc9831`](https://github.com/MoonshotAI/kimi-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice. + +- [#1490](https://github.com/MoonshotAI/kimi-code/pull/1490) [`b30a45e`](https://github.com/MoonshotAI/kimi-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs. + +- [#1480](https://github.com/MoonshotAI/kimi-code/pull/1480) [`2ad0120`](https://github.com/MoonshotAI/kimi-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI. + +- [#1492](https://github.com/MoonshotAI/kimi-code/pull/1492) [`b0809dd`](https://github.com/MoonshotAI/kimi-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. + +## 0.23.1 + +### Patch Changes + +- [#1432](https://github.com/MoonshotAI/kimi-code/pull/1432) [`25a655c`](https://github.com/MoonshotAI/kimi-code/commit/25a655cf88b2f5861f9c0b7ea95ba9308f48d23a) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve prior turns' thinking by default on the Anthropic provider (Claude and Kimi's Anthropic-compatible mode), matching the Kimi default. Disable with `[thinking] keep = "off"` or `KIMI_MODEL_THINKING_KEEP=off`. + +- [#1451](https://github.com/MoonshotAI/kimi-code/pull/1451) [`16dc940`](https://github.com/MoonshotAI/kimi-code/commit/16dc940834d9cc693b1f0022c4c70ef0004a6102) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Recover chat streaming after a stale background-tab WebSocket instead of requiring a page refresh. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal completion and blocked updates to produce one final user-facing outcome summary from the tool result. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal startup and queue handling so failed starts restore permission mode and queued goals wait behind new user messages. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal token budgets to count model completion tokens and stop without extra continuation steps when the budget is exhausted. + +- [#1452](https://github.com/MoonshotAI/kimi-code/pull/1452) [`244ec07`](https://github.com/MoonshotAI/kimi-code/commit/244ec077f98c2b498cee1d0002978b6963ccfd4d) Thanks [@sailist](https://github.com/sailist)! - Fix kimi -p abandoning background subagents that start late or run long, so their results reach the main agent. + +- [#1457](https://github.com/MoonshotAI/kimi-code/pull/1457) [`260a807`](https://github.com/MoonshotAI/kimi-code/commit/260a80793a95d7796950a00bdc89cf99f8b196ad) Thanks [@liruifengv](https://github.com/liruifengv)! - Respect the --skills-dir flag in interactive mode. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/btw []` opening an empty side chat on the new-session screen. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/goal ` silently doing nothing on the new-session screen. + +- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen. + +- [#1437](https://github.com/MoonshotAI/kimi-code/pull/1437) [`743f66e`](https://github.com/MoonshotAI/kimi-code/commit/743f66e547279916d5e37454e78b11eb4b54dca3) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop showing tool-produced `` metadata in tool outputs; failed tools now show their own error text. + +- [#1465](https://github.com/MoonshotAI/kimi-code/pull/1465) [`bfdbce5`](https://github.com/MoonshotAI/kimi-code/commit/bfdbce593f1dd667530cbfb5b10b5659b1968e48) Thanks [@liruifengv](https://github.com/liruifengv)! - Honor explicit Anthropic `max_output_size` settings instead of clamping them to built-in ceilings. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Keep goal tools available to the main agent and return clear messages for invalid goal-control calls. + +- [#1463](https://github.com/MoonshotAI/kimi-code/pull/1463) [`03e78ae`](https://github.com/MoonshotAI/kimi-code/commit/03e78ae19063b38119f27b1bc89097a09614c0ce) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix newer Claude minor versions (e.g. Opus 4.8) defaulting to the family-baseline max output tokens; an uncatalogued minor now reuses the nearest earlier known version's limit. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Show long-running goal wall-clock budget reminders in hours. + +- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance so agents continue reasonable work across turns instead of ending goals prematurely. + +- [#1450](https://github.com/MoonshotAI/kimi-code/pull/1450) [`7a65e0d`](https://github.com/MoonshotAI/kimi-code/commit/7a65e0d1c0da515dbd69f1266ba7e75713e0108e) Thanks [@liruifengv](https://github.com/liruifengv)! - Clarify the permission mode descriptions shown by `/permission`, `/auto`, and `/yolo`, and reorder `/auto` and `/yolo` in the command list. + +- [#1448](https://github.com/MoonshotAI/kimi-code/pull/1448) [`65d3017`](https://github.com/MoonshotAI/kimi-code/commit/65d30177adc11a56bdbbe9fbc3c4b92f96efd6bb) Thanks [@RealKai42](https://github.com/RealKai42)! - Record a per-request trace in the session wire log, so model requests can be reconstructed for debugging. Not a user-facing feature. + +## 0.23.0 + +### Minor Changes + +- [#1417](https://github.com/MoonshotAI/kimi-code/pull/1417) [`79b360c`](https://github.com/MoonshotAI/kimi-code/commit/79b360c96ad5d0af6a8c1d3a8df73adc65254d7c) Thanks [@RealKai42](https://github.com/RealKai42)! - Enable Preserved Thinking by default for Kimi models when Thinking is on, keeping prior reasoning across turns. Set `[thinking] keep = "off"` in config.toml (or `KIMI_MODEL_THINKING_KEEP=off`) to disable it. + +- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - Add server APIs to restore archived sessions and list only archived sessions. + +- [#1369](https://github.com/MoonshotAI/kimi-code/pull/1369) [`f0896a5`](https://github.com/MoonshotAI/kimi-code/commit/f0896a53b01f7e5b9bf5b8f93d2cd7387d765f07) Thanks [@starquakee](https://github.com/starquakee)! - Add experimental progressive tool disclosure (`select_tools`). When the `tool-select` experimental flag is on and the active model declares the `select_tools` capability, MCP tool schemas stay out of the request's top-level `tools[]` (preserving the provider prompt cache); the model loads tools on demand by exact name via the new built-in `select_tools` tool, guided by `/` announcements. Off by default and inert on models without the capability — behavior is unchanged until a supporting model is catalogued. The SDK additionally maps the `select_tools` capability when building model aliases from a catalog and reports the new flag through `getExperimentalFeatures()`. + +- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - web: Add an Archived sessions page in Settings to browse and restore archived sessions. Open Settings → Archived to find it. + +- [#1425](https://github.com/MoonshotAI/kimi-code/pull/1425) [`c5e3e80`](https://github.com/MoonshotAI/kimi-code/commit/c5e3e80041a763143934c271d2524ac555d48d2a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Replace the swarm footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep swarm badges stable after refresh. + +### Patch Changes + +- [#1414](https://github.com/MoonshotAI/kimi-code/pull/1414) [`c12f309`](https://github.com/MoonshotAI/kimi-code/commit/c12f30951f2c278bebb42d0f61d67946c42b9577) Thanks [@RealKai42](https://github.com/RealKai42)! - AskUserQuestion now feeds answers back to the model as question text and option labels (e.g. `{"answers":{"Which database?":"Postgres"}}`) instead of synthesized ids like `q_0`/`opt_0_1`, so the model no longer has to map positional ids back to the original options — the wire protocol is unchanged, clients still answer with option ids. Question texts must now be unique per call and option labels unique per question; the web transcript card resolves both the new label form and legacy id transcripts. + +- [#1408](https://github.com/MoonshotAI/kimi-code/pull/1408) [`fc259ab`](https://github.com/MoonshotAI/kimi-code/commit/fc259abdb415fe9ac10132a142bdb5ce507ccda2) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories. + +- [#1346](https://github.com/MoonshotAI/kimi-code/pull/1346) [`b9258ee`](https://github.com/MoonshotAI/kimi-code/commit/b9258ee07d32ff63afe9a2eb40fce6d136548fb2) Thanks [@liruifengv](https://github.com/liruifengv)! - Show compaction summaries in the TUI after compaction. Press Ctrl-O to show or hide the summary. + +- [#1419](https://github.com/MoonshotAI/kimi-code/pull/1419) [`5ea3ec4`](https://github.com/MoonshotAI/kimi-code/commit/5ea3ec489e0a7d66b844c39ee65162fd6a8ed8b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Bash tool card collapsing in height when a multi-line command finishes with short output, and visually separate the command from its output. + +- [#1410](https://github.com/MoonshotAI/kimi-code/pull/1410) [`1c817df`](https://github.com/MoonshotAI/kimi-code/commit/1c817df1e522f438d4392568b64fc039dc867031) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the edit approval preview shown by ctrl+e to include surrounding context lines, matching the summary panel. + +- [#1421](https://github.com/MoonshotAI/kimi-code/pull/1421) [`1de0286`](https://github.com/MoonshotAI/kimi-code/commit/1de028612c80c38cd6fbe4483c123ded57a0a678) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Edit tool card jumping in height and flickering while its result streams in. + +- [#1389](https://github.com/MoonshotAI/kimi-code/pull/1389) [`ebdffc7`](https://github.com/MoonshotAI/kimi-code/commit/ebdffc7df7b89dcafcf62f5705eafb50bfbaf5ab) Thanks [@sailist](https://github.com/sailist)! - Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns. + +- [#1393](https://github.com/MoonshotAI/kimi-code/pull/1393) [`4c43935`](https://github.com/MoonshotAI/kimi-code/commit/4c43935e31170140699a54cba631792872628655) Thanks [@justjavac](https://github.com/justjavac)! - web: Show the correct session search shortcut on Windows. + +- [#1406](https://github.com/MoonshotAI/kimi-code/pull/1406) [`ce41f4b`](https://github.com/MoonshotAI/kimi-code/commit/ce41f4b58d128ae47b0312eab24a845bbc0d08a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the collapsed sidebar not hiding and squeezing the conversation layout. + +- [#1413](https://github.com/MoonshotAI/kimi-code/pull/1413) [`913d042`](https://github.com/MoonshotAI/kimi-code/commit/913d042208b4bfe45dc13144c4797dba1cac5d05) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the input box shifting upward after the slash command menu closes. + +- [#1433](https://github.com/MoonshotAI/kimi-code/pull/1433) [`ac5b5e4`](https://github.com/MoonshotAI/kimi-code/commit/ac5b5e4cbfdb050817c9fce7e08dd3bdd8ea354e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix tool components jumping the conversation when expanded or collapsed. + +- [#1394](https://github.com/MoonshotAI/kimi-code/pull/1394) [`e95fc83`](https://github.com/MoonshotAI/kimi-code/commit/e95fc83cc295dccf3e4c748fba087530dba614b6) Thanks [@justjavac](https://github.com/justjavac)! - web: Fix the font size setting so chat text, composer text, and sidebar text follow the selected size. + +- [#1411](https://github.com/MoonshotAI/kimi-code/pull/1411) [`e6e6dd5`](https://github.com/MoonshotAI/kimi-code/commit/e6e6dd53ce9106f47684534a91acb1a803d1ab07) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Stop the chat history from replaying its entrance animation every time a session is opened. + +- [#1409](https://github.com/MoonshotAI/kimi-code/pull/1409) [`578f7d3`](https://github.com/MoonshotAI/kimi-code/commit/578f7d334c7919e5987229c157060b1daae30139) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the end of a reply staying missing after reopening a session. + +- [#1390](https://github.com/MoonshotAI/kimi-code/pull/1390) [`083d0ca`](https://github.com/MoonshotAI/kimi-code/commit/083d0caf0524ed9cc7978007cd0f342f6bd2917e) Thanks [@sailist](https://github.com/sailist)! - Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent. + +- [#1357](https://github.com/MoonshotAI/kimi-code/pull/1357) [`be7c991`](https://github.com/MoonshotAI/kimi-code/commit/be7c9916b019b19e057301c39bc7944fcac09414) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message. + +- [#1428](https://github.com/MoonshotAI/kimi-code/pull/1428) [`903e8ed`](https://github.com/MoonshotAI/kimi-code/commit/903e8ed93afc5b35d0fa1d33c86da2b3fae9ba9f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add an Archived sessions entry to the mobile settings sheet and clarify the archive confirmation to mention restoring from Settings. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON. + +- [#1423](https://github.com/MoonshotAI/kimi-code/pull/1423) [`fa6d198`](https://github.com/MoonshotAI/kimi-code/commit/fa6d198b0174ad76aa4ca3c0ea2ed45e099e521b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix an almost-invisible composer input caret and a washed-out strikethrough on completed todos. + +- [#1436](https://github.com/MoonshotAI/kimi-code/pull/1436) [`a5fbcb7`](https://github.com/MoonshotAI/kimi-code/commit/a5fbcb75b4b3ab937536a7a2f621c0374812c753) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the composer toolbar from clipping its controls on narrow windows and phones, with the context ring staying visible at every width. + +- [#1426](https://github.com/MoonshotAI/kimi-code/pull/1426) [`2374bc4`](https://github.com/MoonshotAI/kimi-code/commit/2374bc41c35adc1d2e2b5116559946c8de1b98a8) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Show scheduled-reminder (cron) fires as notice cards in the chat instead of hiding them. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel. + +- [#1434](https://github.com/MoonshotAI/kimi-code/pull/1434) [`4aacddc`](https://github.com/MoonshotAI/kimi-code/commit/4aacddc43222d0a44f202360462617788ca75660) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show the Kimi icon and clearer titles in web desktop notifications. + +- [#1392](https://github.com/MoonshotAI/kimi-code/pull/1392) [`4963c90`](https://github.com/MoonshotAI/kimi-code/commit/4963c9016fa19d1e01f8dc938c8d250afec87965) Thanks [@sailist](https://github.com/sailist)! - web: Show available skills in the composer before a session is created. + +- [#1438](https://github.com/MoonshotAI/kimi-code/pull/1438) [`d86fa38`](https://github.com/MoonshotAI/kimi-code/commit/d86fa38e119c5834fff13a67194efe8d62c117e1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent chat text from hyphenating at line breaks and render code without font ligatures. + +- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header. + +## 0.22.3 + +### Patch Changes + +- [#1367](https://github.com/MoonshotAI/kimi-code/pull/1367) [`23daf0f`](https://github.com/MoonshotAI/kimi-code/commit/23daf0f3c199b4aaa9bd9388a2903d7827f98d32) - Revert the recent TUI transcript rendering changes to the original upstream behavior and fix related rendering issues. + +- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Add click-to-enlarge for images uploaded in the web chat. Click an image in a message to open it. + +- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Fix uploaded videos failing to play in the web chat. + +- [#1371](https://github.com/MoonshotAI/kimi-code/pull/1371) [`5394fea`](https://github.com/MoonshotAI/kimi-code/commit/5394feaabb5d373fab046b3986b10a1180b4991d) - Wait for background subagents to finish and respond to their results before exiting in `kimi -p`, instead of ending the turn early. + +- [#1373](https://github.com/MoonshotAI/kimi-code/pull/1373) [`e715b16`](https://github.com/MoonshotAI/kimi-code/commit/e715b1648c57bd0863edf859cb67db0327b7bb94) - Add `--dangerous-bypass-auth` and `--keep-alive` flags to `kimi server run`, so the server can run without a token on trusted networks and stay alive past the idle timeout. + +- [#1344](https://github.com/MoonshotAI/kimi-code/pull/1344) [`26b9022`](https://github.com/MoonshotAI/kimi-code/commit/26b90225d21bd18f4f7e3b775f3f7f49034afad9) - Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level. + +## 0.22.2 + +### Patch Changes + +- [#1353](https://github.com/MoonshotAI/kimi-code/pull/1353) [`68ad686`](https://github.com/MoonshotAI/kimi-code/commit/68ad686211760eb1c3e6b5c23eb28ace9009c17f) - Fix duplicated transcript content appearing in scrollback during streaming. + +- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix sessions silently dropping later user messages after a turn was interrupted between a tool call and its result. + +- [#1342](https://github.com/MoonshotAI/kimi-code/pull/1342) [`84d8d5b`](https://github.com/MoonshotAI/kimi-code/commit/84d8d5b06399d29a9d8caba701835061c30a4817) - Have context-compaction notes capture a forward plan for the remaining work — upcoming steps, settled decisions, and foreseeable obstacles — instead of only the immediate next step, so the agent continues more coherently after auto-compaction. + +- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix requests being rejected by strict providers when the model emits duplicate tool call ids. + +- [#1339](https://github.com/MoonshotAI/kimi-code/pull/1339) [`021786f`](https://github.com/MoonshotAI/kimi-code/commit/021786f5a201df5466a963d4d1ac915b3977582b) - Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when kimi-code was launched without the full profile PATH. + +- [#1336](https://github.com/MoonshotAI/kimi-code/pull/1336) [`4c1d0a1`](https://github.com/MoonshotAI/kimi-code/commit/4c1d0a1633c98ae5703addbf86ffe50b81545c08) - Keep automatic background updates from flashing a console window on Windows. + +- [#1332](https://github.com/MoonshotAI/kimi-code/pull/1332) [`93f16c3`](https://github.com/MoonshotAI/kimi-code/commit/93f16c32d71d974f30c3ea3b1134691936ac5f53) - Fix `kimi upgrade` failing on Windows with a spawn error when installing the new version. + +- [#1348](https://github.com/MoonshotAI/kimi-code/pull/1348) [`175b95f`](https://github.com/MoonshotAI/kimi-code/commit/175b95f3af684f7c5447967b9fe7c8a58b6ffe1b) - Fix compressed-image prompts leaking an internal `` compression note into the visible message and the session title. + +- [#1338](https://github.com/MoonshotAI/kimi-code/pull/1338) [`276407d`](https://github.com/MoonshotAI/kimi-code/commit/276407d2a46b03ce32cce02b73c5d485b1b02b17) - Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions. + +- [#1347](https://github.com/MoonshotAI/kimi-code/pull/1347) [`02da587`](https://github.com/MoonshotAI/kimi-code/commit/02da5877953ce082826ba5ab1a1abd914d82b24a) - In `kimi -p` runs, wait for background subagents to finish before exiting when `background.keep_alive_on_exit` is enabled. Set `keep_alive_on_exit = true` to let concurrent background subagents complete. + +- [#1349](https://github.com/MoonshotAI/kimi-code/pull/1349) [`e9db9ca`](https://github.com/MoonshotAI/kimi-code/commit/e9db9cafcf7a0d26122b2cac247d866d7724fd7a) - Record model response ids in session wire logs to make individual model requests easier to trace. + +- [#1345](https://github.com/MoonshotAI/kimi-code/pull/1345) [`3ed22e3`](https://github.com/MoonshotAI/kimi-code/commit/3ed22e35a4ee09ce353e699406c6c994423ff39f) - Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window. + +- [#1305](https://github.com/MoonshotAI/kimi-code/pull/1305) [`9091627`](https://github.com/MoonshotAI/kimi-code/commit/909162725770700efd3051f4cfa68156d9b84fa8) - Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off. + +- [#1328](https://github.com/MoonshotAI/kimi-code/pull/1328) [`01b65bd`](https://github.com/MoonshotAI/kimi-code/commit/01b65bdddc28c7c492096000103687f6a507e353) - Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy. + +## 0.22.1 + +### Patch Changes + +- [#1304](https://github.com/MoonshotAI/kimi-code/pull/1304) [`0fc0ae3`](https://github.com/MoonshotAI/kimi-code/commit/0fc0ae380b09aa96aad0eff1ae66f239e061d01a) - When large images are compressed, tell the model the original and delivered image details. Keep the original image available, and support cropped or full-resolution reads for fine details. + +- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear. + +- [#1303](https://github.com/MoonshotAI/kimi-code/pull/1303) [`2639786`](https://github.com/MoonshotAI/kimi-code/commit/2639786ce578f15c020a2c11c344797dae18de61) - Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text. + +- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Clear the screen fully when starting a new session via /new, /clear, or a session switch. + +- [#1301](https://github.com/MoonshotAI/kimi-code/pull/1301) [`c3653a1`](https://github.com/MoonshotAI/kimi-code/commit/c3653a1c50ffa3856484599e132980628eb9fca4) - Show an up arrow on the web composer send button. + +- [#1290](https://github.com/MoonshotAI/kimi-code/pull/1290) [`3ea84a5`](https://github.com/MoonshotAI/kimi-code/commit/3ea84a56e4dfdeaddd58add5b269be0342f3f986) - Fix the session search dialog showing a horizontal scrollbar for long session titles or snippets. + +- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Fix web tooltips that could get stuck on screen when their trigger element is removed while open. + +- [#1319](https://github.com/MoonshotAI/kimi-code/pull/1319) [`e8ab7ca`](https://github.com/MoonshotAI/kimi-code/commit/e8ab7ca78661de7f00a8196444be1db93e7c14b4) - Fix the sidebar session row shifting its title and status badges when hovered. + +- [#1293](https://github.com/MoonshotAI/kimi-code/pull/1293) [`6a469b3`](https://github.com/MoonshotAI/kimi-code/commit/6a469b3e07022e56b29b1fd8a7c58df36b2111fe) - Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips. + +- [#1311](https://github.com/MoonshotAI/kimi-code/pull/1311) [`b40649b`](https://github.com/MoonshotAI/kimi-code/commit/b40649b2ae7a4b6a0aea04e32eba200555393064) - Remove duplicate newline-shortcut handling from the prompt editor. + +- [#1317](https://github.com/MoonshotAI/kimi-code/pull/1317) [`78a058a`](https://github.com/MoonshotAI/kimi-code/commit/78a058acd2fc91de5cca0c1d66d415ee35884889) - Remove the experimental micro compaction feature and its toggle from the experiments panel. + +- [#1283](https://github.com/MoonshotAI/kimi-code/pull/1283) [`ea55911`](https://github.com/MoonshotAI/kimi-code/commit/ea55911062eefcb0414cfddb84c8a4494c45f363) - Improve compaction handoff summaries for more reliable resumed sessions. They now keep the latest intent, key tool results, decisions, open questions, and context to re-check. + +- [#1295](https://github.com/MoonshotAI/kimi-code/pull/1295) [`77eb3a9`](https://github.com/MoonshotAI/kimi-code/commit/77eb3a9fe40c93fa32e335f07160b8128355bab6) - Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands. + +- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Trim redundant and incorrect tooltips in the web UI. + +- [#1320](https://github.com/MoonshotAI/kimi-code/pull/1320) [`444e6b1`](https://github.com/MoonshotAI/kimi-code/commit/444e6b15f0e53b6c4d75d1bfdc0b35639dce6f4c) - Fix the web UI becoming sluggish after opening many sessions. + +- [#1322](https://github.com/MoonshotAI/kimi-code/pull/1322) [`5441ad1`](https://github.com/MoonshotAI/kimi-code/commit/5441ad1838a5cfa1f3df0ca2ee1524e1433fb513) - Let the web sidebar collapse an expanded workspace session list back to its first page. + +## 0.22.0 + +### Minor Changes + +- [#1243](https://github.com/MoonshotAI/kimi-code/pull/1243) [`ace7901`](https://github.com/MoonshotAI/kimi-code/commit/ace79010669d19ad175bc25443b6efb41ca2e2ac) - Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged. + +- [#1262](https://github.com/MoonshotAI/kimi-code/pull/1262) [`c070fbe`](https://github.com/MoonshotAI/kimi-code/commit/c070fbeddeb1c147d8859a76046f9465f696c9cb) - Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."".overrides]`. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Refresh the web UI with a new design system, including updated colors, typography, spacing, light and dark palettes, restyled tooltips, and subtle enter/exit and expand/collapse animations. + +### Patch Changes + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show draft pull requests with a distinct draft status instead of displaying them as open. + +- [#1254](https://github.com/MoonshotAI/kimi-code/pull/1254) [`7859b0a`](https://github.com/MoonshotAI/kimi-code/commit/7859b0afe8898852806e5a0c21b9dd52cb82f834) - Fix the transcript jumping to the top when scrolling up through history during streaming output. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix plan, swarm, and goal modes being shared across sessions in the web UI; each session now keeps its own toggles. + +- [#1264](https://github.com/MoonshotAI/kimi-code/pull/1264) [`003733c`](https://github.com/MoonshotAI/kimi-code/commit/003733c751584ce30d8ebae4f5e608f0df049d32) - Hide the unsupported Off option in the /model thinking switcher for always-on models that already expose multiple effort levels. + +- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions. + +- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit. + +- [#1265](https://github.com/MoonshotAI/kimi-code/pull/1265) [`8cfb165`](https://github.com/MoonshotAI/kimi-code/commit/8cfb1657ad7bf525269df4ab6cf5c12aa1d406a9) - Reduce the default TUI transcript window to keep long sessions responsive. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Replace the Explore and Native theme options with a single chat layout and a Blue or Black accent-color setting. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show time, duration, connection, and stack details in web error and warning toasts. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix an active workspace showing only its five most recent sessions on load, so it now keeps loading older sessions from the last 12 hours. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Reduce the web composer's default height for a more compact empty state, and fix ArrowUp recalling the previous message while editing a multi-line draft; ArrowUp now recalls only from the very start of the text and is disabled in the expanded editor. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix the Thinking-by-default setting not taking effect, so new sessions correctly start with thinking enabled. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix spurious errors from the web question, approval, and task actions when the action was already complete, and add loading feedback so each click is acknowledged immediately. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show queued prompts inline below the running turn in the web chat, and split Stop into its own button so Send no longer interrupts. + +- [#1278](https://github.com/MoonshotAI/kimi-code/pull/1278) [`bbda90a`](https://github.com/MoonshotAI/kimi-code/commit/bbda90af846ca66232158d2e9605d3d59a7e3a49) - Hide the conversation outline when there is not enough room to expand its labels, so it no longer clips against the window edge. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show the conversation outline as one entry per user query that expands into a labeled list on hover. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Remove the fade-out animation when undoing a message in the web chat. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Improve session search with a Cmd/Ctrl+K palette that filters by title, workspace, and last prompt with highlighted matches. Press Cmd+K or Ctrl+K to open it. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Group consecutive tool calls into a collapsible stack with per-tool renderers, including diff line-count chips for edits and inline previews for image, video, and audio results. + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Use one consistent modal dialog for confirmations in the web UI (archive session, delete workspace, delete provider, undo message, and mode toggles). + +- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Add workspace sorting by manual order or last-edited time, plus collapse-all and expand-all controls, to the sidebar. + +## 0.21.1 + +### Patch Changes + +- [#1256](https://github.com/MoonshotAI/kimi-code/pull/1256) [`0cc02ac`](https://github.com/MoonshotAI/kimi-code/commit/0cc02ac67d465d1d4d7fe070422bab17053cdaa3) - Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears. + +## 0.21.0 + +### Minor Changes + +- [#1204](https://github.com/MoonshotAI/kimi-code/pull/1204) [`5cb80ce`](https://github.com/MoonshotAI/kimi-code/commit/5cb80ce879406d239048c32d61202778cb860e58) - Plugins can now provide slash commands via a `commands` field in their manifest, registered as `:` and invoked with `$ARGUMENTS` expansion. + +- [#1214](https://github.com/MoonshotAI/kimi-code/pull/1214) [`86e0c92`](https://github.com/MoonshotAI/kimi-code/commit/86e0c9201ed58c7c1ce5543b1dfb47a4cf5117f6) - Rework conversation compaction: + + - Keep only recent user prompts plus a single user-role summary; drop assistant and tool messages. + - Repair tool_use/tool_result adjacency before sending, fixing a strict-provider HTTP 400 when a tool call and its result became non-adjacent. + - Merge consecutive user turns for strict providers (Gemini/Vertex), fixing an HTTP 400 ("roles must alternate") after compaction or when a turn is steered in right after a tool result. + - Micro-compaction now defaults off. + +- [#1132](https://github.com/MoonshotAI/kimi-code/pull/1132) [`108299b`](https://github.com/MoonshotAI/kimi-code/commit/108299be3cdffc31a23f64efd3ff5ba50976b412) - Refactor the thinking effort system + +### Patch Changes + +- [#1231](https://github.com/MoonshotAI/kimi-code/pull/1231) [`ceb27f5`](https://github.com/MoonshotAI/kimi-code/commit/ceb27f5e449e177493f320d90e292487a8fc3410) - Add a server-side key-value store API for persisting web UI preferences to the user's data directory. + +- [#1220](https://github.com/MoonshotAI/kimi-code/pull/1220) [`ec51324`](https://github.com/MoonshotAI/kimi-code/commit/ec51324230484f2ebaad1ab0aebf2e38f531d914) - Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo. + +- [#1223](https://github.com/MoonshotAI/kimi-code/pull/1223) [`80e6888`](https://github.com/MoonshotAI/kimi-code/commit/80e6888e34e4362247c0eac5b77340df014ba286) - Fix @ file mentions not opening when typed inside a slash command argument. + +- [#1233](https://github.com/MoonshotAI/kimi-code/pull/1233) [`020992c`](https://github.com/MoonshotAI/kimi-code/commit/020992c286f0f6bff6a038a7c7bd7e9db639e3c9) - Force-exit headless runs (`kimi -p`) so a stray ref'd handle left over from the run can't keep a completed run alive until an external timeout, and bound prompt cleanup so a wedged shutdown step can't hang shutdown. + +- [#1225](https://github.com/MoonshotAI/kimi-code/pull/1225) [`659062d`](https://github.com/MoonshotAI/kimi-code/commit/659062d11cc272fe631fc6d4faf64d0e0b1a0142) - Show file path completions when typing `/` in shell mode (`!`). + +- [#1236](https://github.com/MoonshotAI/kimi-code/pull/1236) [`bfe8e6a`](https://github.com/MoonshotAI/kimi-code/commit/bfe8e6ace3cda76b1991bf29c25b9444611d5512) - Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace. + +- [#1221](https://github.com/MoonshotAI/kimi-code/pull/1221) [`a3f9cec`](https://github.com/MoonshotAI/kimi-code/commit/a3f9cec8a975f11e37e992e42f954789ed394207) - Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once. + +- [#1241](https://github.com/MoonshotAI/kimi-code/pull/1241) [`8ac337a`](https://github.com/MoonshotAI/kimi-code/commit/8ac337a2b2ac800aa79a373459308abb6c9e63bb) - Stop a malformed message history from permanently bricking a session on strict providers (Anthropic). The request is repaired before sending — orphaned tool calls are closed and empty/whitespace-only text blocks dropped — and if the provider still rejects its structure, it is resent once with a wire-compliant rebuild. + +- [#1228](https://github.com/MoonshotAI/kimi-code/pull/1228) [`42e37eb`](https://github.com/MoonshotAI/kimi-code/commit/42e37eb898b722829d2ec83e909525ff18e336a5) - 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. + +- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Hide the provider management dialog in the web UI until the server supports it. + +- [#1226](https://github.com/MoonshotAI/kimi-code/pull/1226) [`7f05f58`](https://github.com/MoonshotAI/kimi-code/commit/7f05f589e7bc77a2f26463a41317ff7087e3c3a0) - Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming. + +- [#1232](https://github.com/MoonshotAI/kimi-code/pull/1232) [`aa6b0d0`](https://github.com/MoonshotAI/kimi-code/commit/aa6b0d065ee888056c3812781483ddb74739897f) - Always show the usage-data opt-out toggle in the web settings with a clearer label and description. + +- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Fix the web workspace rename not persisting after a page refresh. + +## 0.20.3 + +### Patch Changes + +- [#1207](https://github.com/MoonshotAI/kimi-code/pull/1207) [`14d9e98`](https://github.com/MoonshotAI/kimi-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting. + +- [#1191](https://github.com/MoonshotAI/kimi-code/pull/1191) [`0df1812`](https://github.com/MoonshotAI/kimi-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page. + +- [#1212](https://github.com/MoonshotAI/kimi-code/pull/1212) [`636ccc4`](https://github.com/MoonshotAI/kimi-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused. + +- [#1068](https://github.com/MoonshotAI/kimi-code/pull/1068) [`c82dcf9`](https://github.com/MoonshotAI/kimi-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable. + +- [#1209](https://github.com/MoonshotAI/kimi-code/pull/1209) [`0635387`](https://github.com/MoonshotAI/kimi-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback. + +## 0.20.2 + +### Patch Changes + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages. + +- [#1156](https://github.com/MoonshotAI/kimi-code/pull/1156) [`794db55`](https://github.com/MoonshotAI/kimi-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors. + +- [#1129](https://github.com/MoonshotAI/kimi-code/pull/1129) [`d02b5c4`](https://github.com/MoonshotAI/kimi-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size. + +- [#1188](https://github.com/MoonshotAI/kimi-code/pull/1188) [`db5fbc5`](https://github.com/MoonshotAI/kimi-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel. + +- [#1187](https://github.com/MoonshotAI/kimi-code/pull/1187) [`97f9263`](https://github.com/MoonshotAI/kimi-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn. + +- [#1163](https://github.com/MoonshotAI/kimi-code/pull/1163) [`ff6e8bb`](https://github.com/MoonshotAI/kimi-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session. + +- [#1189](https://github.com/MoonshotAI/kimi-code/pull/1189) [`04b3492`](https://github.com/MoonshotAI/kimi-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent swarm progress bar. + +- [#1159](https://github.com/MoonshotAI/kimi-code/pull/1159) [`23a553b`](https://github.com/MoonshotAI/kimi-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying. + +- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Kimi Code, including video input. + +- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry. + +- [#1155](https://github.com/MoonshotAI/kimi-code/pull/1155) [`54baf5d`](https://github.com/MoonshotAI/kimi-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements. + +- [#1162](https://github.com/MoonshotAI/kimi-code/pull/1162) [`b070846`](https://github.com/MoonshotAI/kimi-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see. + +- [#1179](https://github.com/MoonshotAI/kimi-code/pull/1179) [`fc3d69d`](https://github.com/MoonshotAI/kimi-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. + +- [#1165](https://github.com/MoonshotAI/kimi-code/pull/1165) [`f3b1532`](https://github.com/MoonshotAI/kimi-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon. + +- [#1167](https://github.com/MoonshotAI/kimi-code/pull/1167) [`c63edd5`](https://github.com/MoonshotAI/kimi-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI. + +- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message. + +- [#1161](https://github.com/MoonshotAI/kimi-code/pull/1161) [`d968642`](https://github.com/MoonshotAI/kimi-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten. + +- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions. + +## 0.20.1 + +### Patch Changes + +- [#1125](https://github.com/MoonshotAI/kimi-code/pull/1125) [`e9a3b7c`](https://github.com/MoonshotAI/kimi-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `kimi upgrade` command. Run `kimi update` to upgrade to the latest version. + +- [#1122](https://github.com/MoonshotAI/kimi-code/pull/1122) [`820d77a`](https://github.com/MoonshotAI/kimi-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel. + +- [#1131](https://github.com/MoonshotAI/kimi-code/pull/1131) [`76c643b`](https://github.com/MoonshotAI/kimi-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors. + +- [#1120](https://github.com/MoonshotAI/kimi-code/pull/1120) [`e736349`](https://github.com/MoonshotAI/kimi-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context. + +- [#1135](https://github.com/MoonshotAI/kimi-code/pull/1135) [`bf51fb7`](https://github.com/MoonshotAI/kimi-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive. + +- [#1102](https://github.com/MoonshotAI/kimi-code/pull/1102) [`9c97161`](https://github.com/MoonshotAI/kimi-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed. + +- [#1127](https://github.com/MoonshotAI/kimi-code/pull/1127) [`184acf5`](https://github.com/MoonshotAI/kimi-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events. + +- [#1128](https://github.com/MoonshotAI/kimi-code/pull/1128) [`0886bff`](https://github.com/MoonshotAI/kimi-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to kimi server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines. + +- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn. + +- [#1121](https://github.com/MoonshotAI/kimi-code/pull/1121) [`81ba48f`](https://github.com/MoonshotAI/kimi-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages. + +- [#1133](https://github.com/MoonshotAI/kimi-code/pull/1133) [`f1c8175`](https://github.com/MoonshotAI/kimi-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits. + +## 0.20.0 + +### Minor Changes + +- [#1079](https://github.com/MoonshotAI/kimi-code/pull/1079) [`2db5fc2`](https://github.com/MoonshotAI/kimi-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands. + Type `!` in the input box to enable it. + The command output is visible to the AI. + For long-running commands, press Ctrl+B to move them to the background. + For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`. + +- [#1088](https://github.com/MoonshotAI/kimi-code/pull/1088) [`0030f76`](https://github.com/MoonshotAI/kimi-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins. + +- [#1066](https://github.com/MoonshotAI/kimi-code/pull/1066) [`3554f7e`](https://github.com/MoonshotAI/kimi-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. + +- [#1025](https://github.com/MoonshotAI/kimi-code/pull/1025) [`5ef66dd`](https://github.com/MoonshotAI/kimi-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed + plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained + marketplace plugins), **Third-party** (marketplace plugins from other + publishers), and **Custom** (install straight from a GitHub URL, zip URL, or + local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party + catalogs load lazily, so `/plugins` opens instantly and keeps working offline — + a marketplace fetch failure is shown inline instead of closing the panel. The + tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` + helper. + +- [#1006](https://github.com/MoonshotAI/kimi-code/pull/1006) [`60dfb68`](https://github.com/MoonshotAI/kimi-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now + requires a per-start bearer token on all API and WebSocket calls (the CLI reads + it automatically), enforces Host/Origin checks, and gains `--host` with a + public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or + `--insecure-no-tls`), auth-failure rate limiting, disabled remote + shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`. + +- [#1040](https://github.com/MoonshotAI/kimi-code/pull/1040) [`6664038`](https://github.com/MoonshotAI/kimi-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI. + +- [#1101](https://github.com/MoonshotAI/kimi-code/pull/1101) [`3ea6ac2`](https://github.com/MoonshotAI/kimi-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI. + +- [#1103](https://github.com/MoonshotAI/kimi-code/pull/1103) [`18f7c34`](https://github.com/MoonshotAI/kimi-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat. + +### Patch Changes + +- [#1072](https://github.com/MoonshotAI/kimi-code/pull/1072) [`a86bb97`](https://github.com/MoonshotAI/kimi-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint. + +- [#1076](https://github.com/MoonshotAI/kimi-code/pull/1076) [`500677a`](https://github.com/MoonshotAI/kimi-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately. + +- [#1067](https://github.com/MoonshotAI/kimi-code/pull/1067) [`0e227ba`](https://github.com/MoonshotAI/kimi-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. + +- [#1075](https://github.com/MoonshotAI/kimi-code/pull/1075) [`3aaf1e5`](https://github.com/MoonshotAI/kimi-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error. + +- [#1094](https://github.com/MoonshotAI/kimi-code/pull/1094) [`8ee5c0f`](https://github.com/MoonshotAI/kimi-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input. + +- [#1057](https://github.com/MoonshotAI/kimi-code/pull/1057) [`ee69e16`](https://github.com/MoonshotAI/kimi-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server. + +- [#1064](https://github.com/MoonshotAI/kimi-code/pull/1064) [`a752a53`](https://github.com/MoonshotAI/kimi-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing. + +- [#903](https://github.com/MoonshotAI/kimi-code/pull/903) [`bbd8a1a`](https://github.com/MoonshotAI/kimi-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected. + +- [#1070](https://github.com/MoonshotAI/kimi-code/pull/1070) [`ff17715`](https://github.com/MoonshotAI/kimi-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer. + +- [#1097](https://github.com/MoonshotAI/kimi-code/pull/1097) [`27ef516`](https://github.com/MoonshotAI/kimi-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option. + +- [#1062](https://github.com/MoonshotAI/kimi-code/pull/1062) [`ea6a4bf`](https://github.com/MoonshotAI/kimi-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. + +- [#1086](https://github.com/MoonshotAI/kimi-code/pull/1086) [`fe667d7`](https://github.com/MoonshotAI/kimi-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one. + +- [#1081](https://github.com/MoonshotAI/kimi-code/pull/1081) [`8fc6aa5`](https://github.com/MoonshotAI/kimi-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode. + +- [#1078](https://github.com/MoonshotAI/kimi-code/pull/1078) [`75ca3b2`](https://github.com/MoonshotAI/kimi-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer. + +- [#1069](https://github.com/MoonshotAI/kimi-code/pull/1069) [`d18aa16`](https://github.com/MoonshotAI/kimi-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks. + +- [#1112](https://github.com/MoonshotAI/kimi-code/pull/1112) [`6a97d0b`](https://github.com/MoonshotAI/kimi-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat. + +- [#1035](https://github.com/MoonshotAI/kimi-code/pull/1035) [`ea03f30`](https://github.com/MoonshotAI/kimi-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula. + +- [#1084](https://github.com/MoonshotAI/kimi-code/pull/1084) [`d6e5246`](https://github.com/MoonshotAI/kimi-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front. + +- [#1113](https://github.com/MoonshotAI/kimi-code/pull/1113) [`6194d3f`](https://github.com/MoonshotAI/kimi-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The + event reducer now reuses the `sessions` array reference for events that do not + change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups` + / `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events. + +- [#1087](https://github.com/MoonshotAI/kimi-code/pull/1087) [`884b65a`](https://github.com/MoonshotAI/kimi-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync. + +- [#1109](https://github.com/MoonshotAI/kimi-code/pull/1109) [`d554f9a`](https://github.com/MoonshotAI/kimi-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. + +- [#1065](https://github.com/MoonshotAI/kimi-code/pull/1065) [`4b837d6`](https://github.com/MoonshotAI/kimi-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file. + +## 0.19.2 + +### Patch Changes + +- [#999](https://github.com/MoonshotAI/kimi-code/pull/999) [`6b68aa8`](https://github.com/MoonshotAI/kimi-code/commit/6b68aa85e2a58cfdaacba5580f66a6a74550ccf6) - Add `-c` as a shorthand for `--continue`. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut. + +- [#1004](https://github.com/MoonshotAI/kimi-code/pull/1004) [`d70c3a8`](https://github.com/MoonshotAI/kimi-code/commit/d70c3a8c0121f55e5f29f9a2ad01b17df449467a) - Show the command in running Bash tool cards and allow expanding it with Ctrl+O before the result arrives. + +- [#1009](https://github.com/MoonshotAI/kimi-code/pull/1009) [`e47de61`](https://github.com/MoonshotAI/kimi-code/commit/e47de610e4de9b11ccd182c0c16387f9d3fb0de4) - Add a Ctrl+T shortcut to expand and collapse a truncated todo list. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks. + +- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix inline images being rendered as broken escape sequences in the transcript. + +- [#1027](https://github.com/MoonshotAI/kimi-code/pull/1027) [`c240bfa`](https://github.com/MoonshotAI/kimi-code/commit/c240bfab7d2b00d41b993f681be612f2db45baa7) - Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate. + +- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Show subcommand suggestions after Tab-completing a slash command name. + +- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Fix the Tab key unexpectedly opening the file completion list. + +- [#1044](https://github.com/MoonshotAI/kimi-code/pull/1044) [`9d197e0`](https://github.com/MoonshotAI/kimi-code/commit/9d197e0f67c879306b9d7659d66e9295e63faa5a) - Fix clipboard copy actions in the web UI when served over plain HTTP. + +- [#1032](https://github.com/MoonshotAI/kimi-code/pull/1032) [`a753b05`](https://github.com/MoonshotAI/kimi-code/commit/a753b0535e44f624289715bd560cf1346149e786) - Fix code blocks nested inside list items rendering blank in the web chat after a turn finishes generating. + +- [#1015](https://github.com/MoonshotAI/kimi-code/pull/1015) [`83384ee`](https://github.com/MoonshotAI/kimi-code/commit/83384ee6d46b37c00b7b8f160a7c48aebbd6921e) - Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages. + +- [#1003](https://github.com/MoonshotAI/kimi-code/pull/1003) [`e15edfd`](https://github.com/MoonshotAI/kimi-code/commit/e15edfd017506fde396b8b0dcf68008b61b39752) - Fix the web question prompt missing the free-text Other option. + +- [#1056](https://github.com/MoonshotAI/kimi-code/pull/1056) [`b93e936`](https://github.com/MoonshotAI/kimi-code/commit/b93e9365b68d53f8f1a148e7349a5865b1b669a8) - Fix yolo mode in the web app auto-approving plan reviews and sensitive file access. + +- [#971](https://github.com/MoonshotAI/kimi-code/pull/971) [`b84704b`](https://github.com/MoonshotAI/kimi-code/commit/b84704bff39ae5cb382d2a8dc0911db286e84ead) - Read large text files in bounded memory and read tail lines without scanning whole files. + +- [#1020](https://github.com/MoonshotAI/kimi-code/pull/1020) [`9c553e4`](https://github.com/MoonshotAI/kimi-code/commit/9c553e4bf7d0a2c09030212fe06577343ea76a60) - Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default. + +- [#1043](https://github.com/MoonshotAI/kimi-code/pull/1043) [`27df39c`](https://github.com/MoonshotAI/kimi-code/commit/27df39c7ed2b012815c380a33fe56bd37c7fc7c1) - Fix web chat stop actions so stale prompt ids fall back to cancelling the active session. + +- [#1036](https://github.com/MoonshotAI/kimi-code/pull/1036) [`866b91c`](https://github.com/MoonshotAI/kimi-code/commit/866b91c8f5dc98dfc18e5c658beaa11afea5032e) - Reorganize the web app's components into area subdirectories (chat/settings/dialogs/mobile) and refresh the component path comments. + +- [#1042](https://github.com/MoonshotAI/kimi-code/pull/1042) [`dc6b9ef`](https://github.com/MoonshotAI/kimi-code/commit/dc6b9ef02bf7583d166c8c5b001a960329c225f8) - Add a development-mode indicator to the web sidebar for local development. + +- [#1047](https://github.com/MoonshotAI/kimi-code/pull/1047) [`98d3e5b`](https://github.com/MoonshotAI/kimi-code/commit/98d3e5b71d5760475f7a5a23b2b794584d12b89b) - Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity; sessions now also float to the top of their group as soon as a new message arrives. + +- [#1034](https://github.com/MoonshotAI/kimi-code/pull/1034) [`603a767`](https://github.com/MoonshotAI/kimi-code/commit/603a7679de91e221802a7f7b0ab7df23c7e5526c) - Extract the composer's image/video attachment handling into a reusable composable. + +- [#1031](https://github.com/MoonshotAI/kimi-code/pull/1031) [`2bfd686`](https://github.com/MoonshotAI/kimi-code/commit/2bfd6860e487f902be53fd5f52f03e66d1839ae2) - Extract the composer's text state and per-session draft persistence into a reusable composable. + +- [#1011](https://github.com/MoonshotAI/kimi-code/pull/1011) [`fb780fc`](https://github.com/MoonshotAI/kimi-code/commit/fb780fce9665e2119cee6d0bc7f85895c6970865) - Extract the composer's shell-style input-history recall into a reusable composable. + +- [#1030](https://github.com/MoonshotAI/kimi-code/pull/1030) [`661c1fb`](https://github.com/MoonshotAI/kimi-code/commit/661c1fbe5b026ec32d80696290a18313b24eafef) - Extract the composer's @-mention menu logic into a reusable composable. + +- [#1026](https://github.com/MoonshotAI/kimi-code/pull/1026) [`318c964`](https://github.com/MoonshotAI/kimi-code/commit/318c964f074123ad228cbddcf7809fa4baaa7fb2) - Extract the composer's slash-command menu logic into a reusable composable. + +- [#1045](https://github.com/MoonshotAI/kimi-code/pull/1045) [`ac1882f`](https://github.com/MoonshotAI/kimi-code/commit/ac1882fe28c906904ffaacd8434bb20e689d6677) - Persist the collapsed state of workspace groups in the web sidebar across page reloads. + +- [#1001](https://github.com/MoonshotAI/kimi-code/pull/1001) [`ea1b33b`](https://github.com/MoonshotAI/kimi-code/commit/ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d) - Extract pure turn-rendering helpers out of the chat pane into their own module. + +- [#1010](https://github.com/MoonshotAI/kimi-code/pull/1010) [`a2650f8`](https://github.com/MoonshotAI/kimi-code/commit/a2650f85d467707e7c85d22cff590f68852d33f3) - Extract the beta conversation outline (table of contents) into its own component. + +- [#998](https://github.com/MoonshotAI/kimi-code/pull/998) [`3e4793d`](https://github.com/MoonshotAI/kimi-code/commit/3e4793d6111059cbfb97159f682ed4bd7a33441d) - Extract the workspace group rendering out of the sidebar into its own component. + +- [#985](https://github.com/MoonshotAI/kimi-code/pull/985) [`92c2cf0`](https://github.com/MoonshotAI/kimi-code/commit/92c2cf0ef57f00928d337bcfeb1d7eff9b0d0f7f) - Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows. + +- [#1033](https://github.com/MoonshotAI/kimi-code/pull/1033) [`b1e6b64`](https://github.com/MoonshotAI/kimi-code/commit/b1e6b6431903fde002fdddbdfcabfab39f3ef5c5) - Optimize the loading tips display. + +## 0.19.1 + +### Patch Changes + +- [#992](https://github.com/MoonshotAI/kimi-code/pull/992) [`7341fb4`](https://github.com/MoonshotAI/kimi-code/commit/7341fb4979523d4429ccf9177b5e3907f544d8c0) - Fix ACP editors such as Zed failing to start a new thread. + +- [#984](https://github.com/MoonshotAI/kimi-code/pull/984) [`da81858`](https://github.com/MoonshotAI/kimi-code/commit/da81858802127cb8bb8ed2deaa1989793b356adf) - Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind. + +- [#978](https://github.com/MoonshotAI/kimi-code/pull/978) [`d4ae02d`](https://github.com/MoonshotAI/kimi-code/commit/d4ae02d82e9da0d163ea4235a54d6535c591172e) - Fix the web sidebar's unread dots getting out of sync across browser tabs. + +- [#979](https://github.com/MoonshotAI/kimi-code/pull/979) [`8c6cade`](https://github.com/MoonshotAI/kimi-code/commit/8c6cade69efa42fdcc280f51a283ea6f717d62fc) - Consolidate web client localStorage access and split the root state store and app shell into focused composables. + +## 0.19.0 + +### Minor Changes + +- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Added the ability to add extra workspace directories: + + - Use the `/add-dir ` command to add extra working directories to the current session, or remember them for the project. + - Use `kimi --add-dir ` to add them on startup. + - Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`. + +- [#975](https://github.com/MoonshotAI/kimi-code/pull/975) [`c5c1834`](https://github.com/MoonshotAI/kimi-code/commit/c5c18347251221fab74e4f452ac4910116c4224d) - Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback. + +### Patch Changes + +- [#910](https://github.com/MoonshotAI/kimi-code/pull/910) [`7644f10`](https://github.com/MoonshotAI/kimi-code/commit/7644f1036ca1079e4527c0b1c825ec5384d6d8da) - Fix provider requests failing when restored conversation history contains empty text content blocks. + +- [#963](https://github.com/MoonshotAI/kimi-code/pull/963) [`4292ae9`](https://github.com/MoonshotAI/kimi-code/commit/4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25) - Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response. + +- [#970](https://github.com/MoonshotAI/kimi-code/pull/970) [`2730079`](https://github.com/MoonshotAI/kimi-code/commit/27300797f2149900219b05dda49dce65e71fa85a) - Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects. + +- [#977](https://github.com/MoonshotAI/kimi-code/pull/977) [`d521932`](https://github.com/MoonshotAI/kimi-code/commit/d521932c3e99a0c5fa1d5d658cf1cd64f0306a75) - Stop showing unread dots on cancelled or failed sessions in the web sidebar. + +- [#957](https://github.com/MoonshotAI/kimi-code/pull/957) [`b57fc90`](https://github.com/MoonshotAI/kimi-code/commit/b57fc905fe480aac07839dd0213768dbeb2a8002) - Fix commands flashing an empty console window on Windows. + +- [#821](https://github.com/MoonshotAI/kimi-code/pull/821) [`ba64072`](https://github.com/MoonshotAI/kimi-code/commit/ba64072559c1e9bb3447ede39991ac2e8bdb7645) - Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel. + +- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Polish file mention UX. + +- [#974](https://github.com/MoonshotAI/kimi-code/pull/974) [`d434d8f`](https://github.com/MoonshotAI/kimi-code/commit/d434d8f0d809599f4ae7de77b58e337bfd4ebcc9) - Unify image format detection when sniffing fails. + +- [#958](https://github.com/MoonshotAI/kimi-code/pull/958) [`98905eb`](https://github.com/MoonshotAI/kimi-code/commit/98905eb409ec643fd916a13beecec85212f834bd) - Show longer branch names in the web chat header and expose the full name on hover. + +- [#964](https://github.com/MoonshotAI/kimi-code/pull/964) [`4223739`](https://github.com/MoonshotAI/kimi-code/commit/42237392ddc3a0816c045da23e77c4875cc692e5) - Keep the web page title fixed instead of changing with the session or workspace name. + +- [#973](https://github.com/MoonshotAI/kimi-code/pull/973) [`3b9938b`](https://github.com/MoonshotAI/kimi-code/commit/3b9938b4c3a386394ed4d35c7b89b48878476977) - Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules. + +## 0.18.0 + +### Minor Changes + +- [#888](https://github.com/MoonshotAI/kimi-code/pull/888) [`58898de`](https://github.com/MoonshotAI/kimi-code/commit/58898de0200d6626ca634e344fe85b860abcfd1b) - Add an environment variable to cap AgentSwarm concurrency during the initial ramp, so large swarms do not trip provider rate limits as easily. + +- [#895](https://github.com/MoonshotAI/kimi-code/pull/895) [`495fe8c`](https://github.com/MoonshotAI/kimi-code/commit/495fe8c674d654cdf87217ca4ada775507f861f6) - Add instant session search to the web sidebar, filtering by title and the last user prompt. + +### Patch Changes + +- [#896](https://github.com/MoonshotAI/kimi-code/pull/896) [`de610de`](https://github.com/MoonshotAI/kimi-code/commit/de610deb5f760606b82cc595e59c5176cc66ce82) - Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1. + +- [#876](https://github.com/MoonshotAI/kimi-code/pull/876) [`49183d8`](https://github.com/MoonshotAI/kimi-code/commit/49183d8729e3e7d361a253dc5c68f409e6382ba9) - Suggest `/reload` alongside `/new` in plugin-change hints. + +- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Redesign the web OAuth login dialog: lead with a single "Authorize in browser" button that opens the verification link with the device code already embedded, demote manual code entry to a clearly secondary fallback, and drop the duplicate open-browser and cancel controls so the order of steps is unambiguous. + +- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Fix the web login slash command description to match the browser authorization flow. + +- [#893](https://github.com/MoonshotAI/kimi-code/pull/893) [`d7ec056`](https://github.com/MoonshotAI/kimi-code/commit/d7ec05686a09580f9ffd99f6ef26385aed8eb02c) - Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock. + +- [#882](https://github.com/MoonshotAI/kimi-code/pull/882) [`8ab9e96`](https://github.com/MoonshotAI/kimi-code/commit/8ab9e969637ffee18b09a0b265ffa860c5a2e11c) - Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable. + +- [#889](https://github.com/MoonshotAI/kimi-code/pull/889) [`23277a5`](https://github.com/MoonshotAI/kimi-code/commit/23277a574c7e0782c04f62e10370494247be3a66) - Show the connected server version in the web settings General tab. + +- [#881](https://github.com/MoonshotAI/kimi-code/pull/881) [`7bc3d99`](https://github.com/MoonshotAI/kimi-code/commit/7bc3d99933b0bbc3f9188a2b02bcc90e81623f72) - Keep the highlighted web slash command visible while navigating a long slash menu. + +- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Allow long web slash command names and descriptions to wrap without overflowing the slash menu. + +- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Fix web slash skill selection sending immediately and allow slash search to match skill names by substring. + +## 0.17.1 + +### Patch Changes + +- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Prevent the web login dialog from closing when clicking the backdrop. + +- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Stop the background local server from locking the directory it was started in. + +- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Fix the local server failing to start in the background on the native binary. + +- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Group the default model dropdown in web settings by provider. + +## 0.17.0 + +### Minor Changes + +- [#625](https://github.com/MoonshotAI/kimi-code/pull/625) [`9a8fea5`](https://github.com/MoonshotAI/kimi-code/commit/9a8fea5c85177cd887896108c05ba9e174f28250) - Add the server-hosted web UI and the CLI commands that power it: + + - `kimi server` to start, stop, and manage the local server. + - `kimi web` to open the server-hosted web UI in a browser. + - Server REST and WebSocket APIs for the web client. + - Web chat layout, session list, auto-scroll, and related behaviors. + +### Patch Changes + +- [#838](https://github.com/MoonshotAI/kimi-code/pull/838) [`843a731`](https://github.com/MoonshotAI/kimi-code/commit/843a731097fc18b2e41ab0405b5fbcb6149ba55c) - Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level. + +- [#849](https://github.com/MoonshotAI/kimi-code/pull/849) [`254f946`](https://github.com/MoonshotAI/kimi-code/commit/254f946a506b01df7a559ed63bd8d705e9fa7496) - Skip debug TPS when the output stream is too short to measure reliably. + +- [#833](https://github.com/MoonshotAI/kimi-code/pull/833) [`a71b2e3`](https://github.com/MoonshotAI/kimi-code/commit/a71b2e3123ff8454f725b3d24e8c985608c5c4f9) - Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history. + +- [#853](https://github.com/MoonshotAI/kimi-code/pull/853) [`05fe759`](https://github.com/MoonshotAI/kimi-code/commit/05fe7595ab9bac8230fd9f2fe7bdbaaa157ddc9b) - Fix the web login page and no-workspace conversation startup flow. + +## 0.16.0 + +### Minor Changes + +- [#788](https://github.com/MoonshotAI/kimi-code/pull/788) [`efdf8a1`](https://github.com/MoonshotAI/kimi-code/commit/efdf8a1b2d4e906fbb35620083c3e7b490e0e88a) - Add a built-in `kimi vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `kimi vis ` deep-links. + +### Patch Changes + +- [#790](https://github.com/MoonshotAI/kimi-code/pull/790) [`d0d5821`](https://github.com/MoonshotAI/kimi-code/commit/d0d58219007cd9d7355f1ea8900e9777b66abda2) - Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers. + +- [#809](https://github.com/MoonshotAI/kimi-code/pull/809) [`6f442bd`](https://github.com/MoonshotAI/kimi-code/commit/6f442bd8cde29e21526fa36c9836e2d4c282b4bf) - Add configurable banner display frequencies with local display state. + +- [#807](https://github.com/MoonshotAI/kimi-code/pull/807) [`b45672c`](https://github.com/MoonshotAI/kimi-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed. + +- [#813](https://github.com/MoonshotAI/kimi-code/pull/813) [`7b5b818`](https://github.com/MoonshotAI/kimi-code/commit/7b5b8188157ec902e5cd4e73545bc5ca6c52bb76) - Fix repeated compaction handling when context remains over the blocking threshold. + +- [#801](https://github.com/MoonshotAI/kimi-code/pull/801) [`ff332be`](https://github.com/MoonshotAI/kimi-code/commit/ff332be6d364ce3d5974133deb7c76220684181a) - Polish queue pane styling + +- [#802](https://github.com/MoonshotAI/kimi-code/pull/802) [`aa1896c`](https://github.com/MoonshotAI/kimi-code/commit/aa1896ca749e41a67d7c4b655dcc8be830cbec82) - Reduce the maximum height of the /btw side panel from half to one-third of the terminal. + +- [#805](https://github.com/MoonshotAI/kimi-code/pull/805) [`3e6196e`](https://github.com/MoonshotAI/kimi-code/commit/3e6196e6b227c66860651f4335e06973865b2714) - Project session replay ranges over rendered replay records instead of raw persisted records. + +- [#804](https://github.com/MoonshotAI/kimi-code/pull/804) [`299b9fc`](https://github.com/MoonshotAI/kimi-code/commit/299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c) - Prevent session shutdown from resuming the agent when stopping background tasks. + +- [#823](https://github.com/MoonshotAI/kimi-code/pull/823) [`90fc04b`](https://github.com/MoonshotAI/kimi-code/commit/90fc04b7072ec20055022c50583d35286ca715a6) - Remove redundant LLM request logging context plumbing. + +## 0.15.0 + +### Minor Changes + +- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories. + +- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. + +### Patch Changes + +- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files. + +- [#780](https://github.com/MoonshotAI/kimi-code/pull/780) [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls. + +- [#786](https://github.com/MoonshotAI/kimi-code/pull/786) [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata. + +- [#768](https://github.com/MoonshotAI/kimi-code/pull/768) [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded. + +- [#775](https://github.com/MoonshotAI/kimi-code/pull/775) [`3fa1b8e`](https://github.com/MoonshotAI/kimi-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system. + +- [#343](https://github.com/MoonshotAI/kimi-code/pull/343) [`73be7ba`](https://github.com/MoonshotAI/kimi-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility. + +- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them. + +- [#766](https://github.com/MoonshotAI/kimi-code/pull/766) [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer. + +- [#765](https://github.com/MoonshotAI/kimi-code/pull/765) [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions. + +- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions. + +- [#785](https://github.com/MoonshotAI/kimi-code/pull/785) [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. + +- [#784](https://github.com/MoonshotAI/kimi-code/pull/784) [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations. + +- [#772](https://github.com/MoonshotAI/kimi-code/pull/772) [`d47e699`](https://github.com/MoonshotAI/kimi-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files. + +- [#783](https://github.com/MoonshotAI/kimi-code/pull/783) [`e2a407c`](https://github.com/MoonshotAI/kimi-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width. + +- [#776](https://github.com/MoonshotAI/kimi-code/pull/776) [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider. + +- [#767](https://github.com/MoonshotAI/kimi-code/pull/767) [`a355f2a`](https://github.com/MoonshotAI/kimi-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream. + +- [#787](https://github.com/MoonshotAI/kimi-code/pull/787) [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form. + +## 0.14.3 + +### Patch Changes + +- [#713](https://github.com/MoonshotAI/kimi-code/pull/713) [`f874251`](https://github.com/MoonshotAI/kimi-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker. + +## 0.14.2 + +### Patch Changes + +- [#683](https://github.com/MoonshotAI/kimi-code/pull/683) [`ad239cb`](https://github.com/MoonshotAI/kimi-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. + +- [#690](https://github.com/MoonshotAI/kimi-code/pull/690) [`7f0dde2`](https://github.com/MoonshotAI/kimi-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. + +- [#651](https://github.com/MoonshotAI/kimi-code/pull/651) [`c39c625`](https://github.com/MoonshotAI/kimi-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. + +- [#617](https://github.com/MoonshotAI/kimi-code/pull/617) [`911e7c3`](https://github.com/MoonshotAI/kimi-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session. + +- [#676](https://github.com/MoonshotAI/kimi-code/pull/676) [`dcf3075`](https://github.com/MoonshotAI/kimi-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running. + +- [#692](https://github.com/MoonshotAI/kimi-code/pull/692) [`7ca9bdf`](https://github.com/MoonshotAI/kimi-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker. + +- [#675](https://github.com/MoonshotAI/kimi-code/pull/675) [`d1ba145`](https://github.com/MoonshotAI/kimi-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. + +- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start. + +## 0.14.1 + +### Patch Changes + +- [#643](https://github.com/MoonshotAI/kimi-code/pull/643) [`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. + +- [#661](https://github.com/MoonshotAI/kimi-code/pull/661) [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. + +- [#604](https://github.com/MoonshotAI/kimi-code/pull/604) [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. + +- [#632](https://github.com/MoonshotAI/kimi-code/pull/632) [`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. + +- [#628](https://github.com/MoonshotAI/kimi-code/pull/628) [`0ee9106`](https://github.com/MoonshotAI/kimi-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. + +- [#658](https://github.com/MoonshotAI/kimi-code/pull/658) [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. + +- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. + +- [#644](https://github.com/MoonshotAI/kimi-code/pull/644) [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. + +- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. + +- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. + +- [#648](https://github.com/MoonshotAI/kimi-code/pull/648) [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. + +- [#641](https://github.com/MoonshotAI/kimi-code/pull/641) [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. + +- [#645](https://github.com/MoonshotAI/kimi-code/pull/645) [`1b58aa8`](https://github.com/MoonshotAI/kimi-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting swarm tasks from Manual mode. + +- [#655](https://github.com/MoonshotAI/kimi-code/pull/655) [`1e2e679`](https://github.com/MoonshotAI/kimi-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup. + +## 0.14.0 + +### Minor Changes + +- [#607](https://github.com/MoonshotAI/kimi-code/pull/607) [`b253a82`](https://github.com/MoonshotAI/kimi-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. + +### Patch Changes + +- [#626](https://github.com/MoonshotAI/kimi-code/pull/626) [`856ec00`](https://github.com/MoonshotAI/kimi-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. + +## 0.13.1 + +### Patch Changes + +- [#610](https://github.com/MoonshotAI/kimi-code/pull/610) [`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. + +- [#615](https://github.com/MoonshotAI/kimi-code/pull/615) [`494554e`](https://github.com/MoonshotAI/kimi-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. + +- [#598](https://github.com/MoonshotAI/kimi-code/pull/598) [`32d7080`](https://github.com/MoonshotAI/kimi-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. + +- [#595](https://github.com/MoonshotAI/kimi-code/pull/595) [`1580f35`](https://github.com/MoonshotAI/kimi-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment. + +- [#619](https://github.com/MoonshotAI/kimi-code/pull/619) [`1fbe0e4`](https://github.com/MoonshotAI/kimi-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width. + +- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. + +- [#540](https://github.com/MoonshotAI/kimi-code/pull/540) [`2ebe387`](https://github.com/MoonshotAI/kimi-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. + +- [#606](https://github.com/MoonshotAI/kimi-code/pull/606) [`a1b419a`](https://github.com/MoonshotAI/kimi-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. + +## 0.13.0 + +### Minor Changes + +- [#484](https://github.com/MoonshotAI/kimi-code/pull/484) [`f863127`](https://github.com/MoonshotAI/kimi-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command. + +- [#582](https://github.com/MoonshotAI/kimi-code/pull/582) [`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. + +- [#593](https://github.com/MoonshotAI/kimi-code/pull/593) [`40506f4`](https://github.com/MoonshotAI/kimi-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. + +### Patch Changes + +- [#587](https://github.com/MoonshotAI/kimi-code/pull/587) [`0abde86`](https://github.com/MoonshotAI/kimi-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time. + +- [#594](https://github.com/MoonshotAI/kimi-code/pull/594) [`f2863af`](https://github.com/MoonshotAI/kimi-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened. + +- [#591](https://github.com/MoonshotAI/kimi-code/pull/591) [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. + +- [#586](https://github.com/MoonshotAI/kimi-code/pull/586) [`7cb4a23`](https://github.com/MoonshotAI/kimi-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width. + +## 0.12.1 + +### Patch Changes + +- [#584](https://github.com/MoonshotAI/kimi-code/pull/584) [`11bb62c`](https://github.com/MoonshotAI/kimi-code/commit/11bb62c12f38d380a0ca1bb89ee2df67f93300e1) - Allow obsolete experimental config entries to remain without blocking startup. + +- [#581](https://github.com/MoonshotAI/kimi-code/pull/581) [`aa3471f`](https://github.com/MoonshotAI/kimi-code/commit/aa3471f5d3d2960834ba3239c0b8459144bc79fa) - Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests. + ## 0.12.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 50a3bfdad..75502e647 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.12.0", + "version": "0.23.4", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -27,15 +27,21 @@ }, "files": [ "dist", + "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" ], "type": "module", "imports": { + "#/tui/theme": "./src/tui/theme/index.ts", + "#/cli/sub/server": "./src/cli/sub/server/index.ts", + "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", "#/*": [ "./src/*.ts", - "./src/*/index.ts" + "./src/*/index.ts", + "./src/*.d.ts" ] }, "publishConfig": { @@ -43,7 +49,8 @@ "provenance": true }, "scripts": { - "build": "tsdown", + "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", "build:native:js": "node scripts/native/01-bundle.mjs", @@ -55,6 +62,8 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", @@ -65,28 +74,34 @@ "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", "postinstall": "node scripts/postinstall.mjs" }, - "dependencies": { - "@earendil-works/pi-tui": "^0.74.0", - "@mariozechner/clipboard": "^0.3.2", - "chalk": "^5.4.1", - "cli-highlight": "^2.1.11", - "commander": "^13.1.0", - "pathe": "^2.0.3", - "semver": "^7.7.4", - "smol-toml": "^1.6.1", - "zod": "^4.3.6" + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.9", + "node-pty": "^1.1.0" }, "devDependencies": { "@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/pi-tui": "workspace:^", + "@moonshot-ai/server": "workspace:^", + "@moonshot-ai/vis-server": "workspace:^", + "@moonshot-ai/vis-web": "workspace:*", "@types/semver": "^7.7.0", "@types/yazl": "^2.4.6", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "commander": "^13.1.0", + "jimp": "^1.6.1", + "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", "tsx": "^4.21.0", - "yazl": "^3.3.1" + "yazl": "^3.3.1", + "zod": "^4.3.6" }, "engines": { "node": ">=22.19.0" diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index 59d05fa86..eaa8d331f 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -6,6 +6,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -46,7 +48,13 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { continue; } const result = await materializeEntrySource(entry.source, pluginsRoot, outDir); - plugins.push({ ...entry, source: result.source }); + let stamped = { ...entry, source: result.source }; + if (isLocalRelativeSource(entry.source)) { + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source)); + if (version !== undefined) stamped = { ...stamped, version }; + } + plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); } diff --git a/apps/kimi-code/scripts/build-vis-asset.mjs b/apps/kimi-code/scripts/build-vis-asset.mjs new file mode 100644 index 000000000..962e37392 --- /dev/null +++ b/apps/kimi-code/scripts/build-vis-asset.mjs @@ -0,0 +1,54 @@ +// Builds the vis web single-file bundle, gzips it, and writes a generated +// TS module that embeds it as base64 so tsdown can later bundle it into +// dist/main.mjs (works identically for the npm package and the native SEA +// binary). +import { execSync } from 'node:child_process'; +import { gzipSync } from 'node:zlib'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const visWeb = join(repoRoot, 'apps', 'vis', 'web'); +const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts'); + +console.log('[build-vis-asset] building vis web single-file bundle…'); +try { + // Run vite with VIS_SINGLEFILE set on the spawn so the build is + // cross-platform (Node sets the env, not a POSIX-only inline-env shell + // prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up + // vis-web's vite.config.ts, which gates the single-file output on + // `process.env.VIS_SINGLEFILE === '1'`. + // execSync runs through the platform shell, which is required on Windows: + // pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no + // PATHEXT without a shell). The win32 native binary IS built on Windows + // runners (.github/workflows/_native-build.yml), which run this generator. + // A single command string (not an args array) avoids the args+shell + // deprecation; the command is static (no injection surface). + execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', { + stdio: 'inherit', + cwd: repoRoot, + env: { ...process.env, VIS_SINGLEFILE: '1' }, + }); +} catch (err) { + throw new Error( + `[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`, + ); +} + +const html = readFileSync(join(visWeb, 'dist-single', 'index.html')); +if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes(' { + console.error(`[dev:server:restart] spawn error: ${err.message}`); + }); + + child.on('exit', (code, signal) => { + if (killTimer !== null) { + clearTimeout(killTimer); + killTimer = null; + } + const prev = child; + child = null; + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (restarting) { + restarting = false; + start(); + return; + } + // Server died on its own (port conflict, runtime error, etc.). Stay alive + // so the user can fix the issue and press Enter to retry. + const tag = signal !== null ? `signal=${signal}` : `code=${code}`; + console.error( + `[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`, + ); + void prev; // silence unused warning + }); +} + +function restart() { + if (shuttingDown) return; + if (child === null) { + // Previous run already exited; just spin up a new one. + start(); + return; + } + if (restarting) return; // debounce — multiple Enters during shutdown collapse + restarting = true; + console.error('[dev:server:restart] restarting…'); + child.kill('SIGTERM'); + // Safety net: if the child ignores SIGTERM, force-kill after 5s so the + // restart loop doesn't wedge. + killTimer = setTimeout(() => { + if (child !== null && child.exitCode === null && child.signalCode === null) { + console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL'); + child.kill('SIGKILL'); + } + }, 5000); +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + // Any newline (Enter on most terminals) triggers a restart. Empty Enter is + // the canonical signal; typing `r` works too. + if (chunk.includes('\n') || chunk.includes('\r')) { + restart(); + } +}); + +const onShutdownSignal = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + if (child !== null) { + child.kill(signal); + // Give the server a moment to flush logs / release the lock. + setTimeout(() => process.exit(0), 1000).unref(); + } else { + process.exit(0); + } +}; +process.on('SIGINT', () => onShutdownSignal('SIGINT')); +process.on('SIGTERM', () => onShutdownSignal('SIGTERM')); + +start(); diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 5bf5d460a..3f50b969c 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -1,31 +1,64 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs'; +const require = createRequire(import.meta.url); const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const APP_ROOT = resolve(SCRIPT_DIR, '..'); +// Monorepo root. Used as the dev CLI's working directory so `make dev` opens +// the whole repo instead of just apps/kimi-code. +const REPO_ROOT = resolve(APP_ROOT, '../..'); +// Runtime variable the CLI reads to locate the marketplace JSON. const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// Opt-in for dev: point this run at an external marketplace instead of a local one. +const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL'; let marketplaceServer; const env = { ...process.env }; -if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) { +const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim(); +if (externalUrl !== undefined && externalUrl.length > 0) { + // Explicitly asked to use an external marketplace; don't start a local server. + env[MARKETPLACE_ENV] = externalUrl; + console.error(`Using external plugin marketplace: ${externalUrl}`); +} else { + // Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a + // random port, so multiple concurrent dev instances never collide. Overwrite any + // inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run. + const inherited = process.env[MARKETPLACE_ENV]?.trim(); marketplaceServer = await startPluginMarketplaceServer(); env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl; console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); + if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) { + console.error( + `(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`, + ); + } } -const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; +const tsxCli = require.resolve('tsx/cli'); const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( - tsxBin, - ['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], + process.execPath, + [ + tsxCli, + // Use the dev tsconfig whose `include` covers packages/*/src, so tsx's + // esbuild transform sees `experimentalDecorators: true` for DI parameter + // decorators in agent-core. Mirrors `dev:server` in package.json. + '--tsconfig', + resolve(APP_ROOT, 'tsconfig.dev.json'), + '--import', + pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href, + resolve(APP_ROOT, 'src/main.ts'), + ...cliArgs, + ], { - cwd: APP_ROOT, + cwd: REPO_ROOT, env, stdio: 'inherit', }, diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs index 46193d3e7..df46acc11 100644 --- a/apps/kimi-code/scripts/native/01-bundle.mjs +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -6,8 +6,14 @@ import { run } from './exec.mjs'; const requireFromScript = createRequire(import.meta.url); const tsdownCliPath = requireFromScript.resolve('tsdown/run'); const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs'); +const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs'); export async function runBundleStep() { + // Generate the embedded `kimi vis` web asset before bundling. The native + // tsdown run here never goes through the npm `prebuild` lifecycle, so the + // generated module must be produced explicitly first or the bundle would + // miss it (npm builds get it via the `prebuild` script). + await run(process.execPath, [buildVisAssetPath]); await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']); await run(process.execPath, [checkBundlePath]); } diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 8bac05a84..434a7861c 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -16,6 +16,7 @@ import { nativeSeaConfigPath, targetTriple, } from './paths.mjs'; +import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; async function ensureBundleExists() { try { @@ -31,13 +32,19 @@ async function writeSeaConfig(target) { appRoot, target, }); + const web = await collectWebAssets({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(webManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); + await writeFile(webManifestPath, web.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, + [webAssetManifestKey(target)]: webManifestPath, ...assets, + ...web.assets, }; const config = { main: nativeJsBundlePath(), @@ -55,6 +62,9 @@ async function writeSeaConfig(target) { for (const line of nativeAssetSummary(manifest)) { console.log(`- ${line}`); } + console.log( + `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 7b0560b69..859262449 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze( SUPPORTED_TARGETS.map((t) => { const deps = resolveTargetDeps(t); const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; - const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; - const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; - return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + return [t, { clipboardPackage: clipboardTarget }]; }), ), ); @@ -161,16 +159,19 @@ async function collectPackageFiles({ packageName, packageRoot, includeNativeFiles, + includeEntryJs = true, nativeFileRelatives = [], }) { const packageJsonPath = join(packageRoot, 'package.json'); const packageJson = await readJson(packageJsonPath); const selected = new Set([packageJsonPath]); - const entry = resolvePackageEntry(packageRoot, packageJson); - if (entry !== null) { - selected.add(entry); - await addRuntimeDependencyFiles(packageRoot, entry, selected); + if (includeEntryJs) { + const entry = resolvePackageEntry(packageRoot, packageJson); + if (entry !== null) { + selected.add(entry); + await addRuntimeDependencyFiles(packageRoot, entry, selected); + } } for (const nativeFileRelative of nativeFileRelatives) { @@ -250,6 +251,7 @@ export async function collectNativeAssets({ appRoot, target }) { packageName: dep.resolvedName, packageRoot, includeNativeFiles: dep.collect === 'native-files', + includeEntryJs: dep.collect !== 'native-file-only', nativeFileRelatives: dep.nativeFileRelatives, }); const result = await packageManifestEntries({ diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index a0479f209..39fd70b45 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -18,10 +18,12 @@ const optionalRuntimeRequires = new Set([ 'canvas', 'chokidar', 'cpu-features', + 'fast-json-stringify/lib/serializer', + 'fast-json-stringify/lib/validator', 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['koffi']); +const handledNativeRuntimeRequires = new Set(); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 910738943..30d5e9da3 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,4 +1,5 @@ export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const WEB_ASSET_MANIFEST_VERSION = 1; export function buildManifestKey(target) { return `native/${target}/manifest.json`; @@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) { export function buildAssetKey(target, packageRoot, relativePath) { return `native/${target}/${packageRoot}/${relativePath}`; } + +export function buildWebManifestKey(target) { + return `web/${target}/manifest.json`; +} + +export function buildWebAssetKey(target, relativePath) { + return `web/${target}/dist-web/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index f195a3cb1..8e26d9229 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -27,13 +27,16 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); -const koffiTripletByTarget = Object.freeze({ - 'darwin-arm64': 'darwin_arm64', - 'darwin-x64': 'darwin_x64', - 'linux-arm64': 'linux_arm64', - 'linux-x64': 'linux_x64', - 'win32-arm64': 'win32_arm64', - 'win32-x64': 'win32_x64', +// pi-tui ships platform-specific native helpers (no Linux build): +// - darwin: Shift-modifier detection for Terminal.app Shift+Enter +// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable +const piTuiNativeFileByTarget = Object.freeze({ + 'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'], + 'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'], + 'linux-arm64': [], + 'linux-x64': [], + 'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'], + 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], }); export function isSupportedTarget(target) { @@ -45,13 +48,15 @@ export function isSupportedTarget(target) { * @property {string} id — stable internal id used for parent refs * @property {(target: string) => string} name * — npm package name (may depend on target) - * @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect + * @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect * @property {string|null} parent * — id of another registered dep this nests under (for pnpm), * or null for top-level (resolvable from app root) * @property {(target: string) => string[]} [nativeFileRelatives] * — explicit list of .node files relative to package root - * (used by 'js-and-native-file'; native-files mode auto-scans *.node) + * (used by 'js-and-native-file' and 'native-file-only'; + * native-files mode auto-scans *.node). 'native-file-only' collects + * package.json + these .node files but skips the package entry JS. */ /** @type {readonly NativeDepDescriptor[]} */ @@ -70,18 +75,14 @@ export const nativeDeps = Object.freeze([ }, { id: 'pi-tui', - name: () => '@earendil-works/pi-tui', - // pi-tui is bundled into main.cjs at build time — we don't collect it as - // a native dep, only register it so koffi can declare it as parent. - collect: 'virtual', + name: () => '@moonshot-ai/pi-tui', + // pi-tui's JS is bundled into main.cjs, so only the platform-specific + // native helper (.node under native/) ships alongside the binary — its + // dist/ JS is intentionally NOT collected (it stays in the bundle). This + // keeps the SEA native-asset payload small. Linux has no native helper. + collect: 'native-file-only', parent: null, - }, - { - id: 'koffi', - name: () => 'koffi', - collect: 'js-and-native-file', - parent: 'pi-tui', - nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], + nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, ]); diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs new file mode 100644 index 000000000..8f8a893c5 --- /dev/null +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { + WEB_ASSET_MANIFEST_VERSION, + buildWebAssetKey, + buildWebManifestKey, +} from './manifest.mjs'; + +export { WEB_ASSET_MANIFEST_VERSION }; + +const WEB_ASSETS_DIR = 'dist-web'; + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) { + const requiredPath = join(assetRoot, requiredFile); + try { + const info = await stat(requiredPath); + if (!info.isFile()) { + throw new Error(`${requiredFile} is not a file`); + } + } catch { + throw new Error(message); + } +} + +export function webAssetManifestKey(target) { + return buildWebManifestKey(target); +} + +export function webAssetKey(target, relativePath) { + return buildWebAssetKey(target, relativePath); +} + +async function collectAssetRoot({ + appRoot, + target, + root, + requiredFile, + missingMessage, + assetKey, +}) { + const assetRoot = resolve(appRoot, ...root.split('/')); + await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); + + const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b)); + const manifestFiles = []; + const assets = {}; + + for (const file of files) { + if (!existsSync(file)) continue; + const bytes = await readFile(file); + const relativePath = toPosixPath(relative(assetRoot, file)); + const key = assetKey(target, relativePath); + manifestFiles.push({ + assetKey: key, + relativePath, + sha256: sha256(bytes), + }); + assets[key] = file; + } + + const manifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target, + root, + files: manifestFiles, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} + +export async function collectWebAssets({ appRoot, target }) { + const buildCommand = + 'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: WEB_ASSETS_DIR, + requiredFile: 'index.html', + missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`, + assetKey: webAssetKey, + }); +} diff --git a/apps/kimi-code/scripts/plugin-manifest-version.mjs b/apps/kimi-code/scripts/plugin-manifest-version.mjs new file mode 100644 index 000000000..1fd768af5 --- /dev/null +++ b/apps/kimi-code/scripts/plugin-manifest-version.mjs @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// Read a local plugin directory's declared version from its manifest, mirroring +// the plugin loader's precedence (packages/agent-core/src/plugin/manifest.ts): +// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json` +// is only consulted when the root manifest is absent. Returns undefined when no +// manifest is present or the chosen manifest has no version — callers then leave +// the marketplace entry's existing version untouched. +export async function readPluginManifestVersion(pluginDir) { + for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) { + const raw = await readFileOrUndefined(resolve(pluginDir, rel)); + if (raw === undefined) continue; // manifest absent — fall back to the next candidate + return versionFromManifest(raw); // the chosen manifest wins, even if it has no version + } + return undefined; +} + +async function readFileOrUndefined(file) { + try { + return await readFile(file, 'utf8'); + } catch { + return undefined; + } +} + +function versionFromManifest(raw) { + try { + const parsed = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') { + const trimmed = parsed.version.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + } catch { + return undefined; + } + return undefined; +} diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index 9c64dd80d..b4a5afa09 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); +const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -23,6 +24,14 @@ async function ensureBundleExists() { } } +async function ensureRuntimeAssetsExist() { + try { + await stat(webIndexPath); + } catch { + fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`); + } +} + async function runBundle(args) { try { const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { @@ -45,6 +54,7 @@ function assertIncludes(output, expected, command) { } await ensureBundleExists(); +await ensureRuntimeAssetsExist(); const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); @@ -55,4 +65,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runBundle(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +const webHelpOutput = await runBundle(['web', '--help']); +assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help'); + console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..32a65eb0e 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,8 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerServerCommand } from './sub/server'; +import { registerVisCommand } from './sub/vis'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -42,7 +44,8 @@ export function createProgram( .hideHelp() .argParser((val: string | boolean) => (val === true ? '' : (val as string))), ) - .option('-C, --continue', 'Continue the previous session for the working directory.', false) + .option('-c, --continue', 'Continue the previous session for the working directory.', false) + .addOption(new Option('-C').hideHelp().default(false)) .option('-y, --yolo', 'Automatically approve all actions.', false) .option('--auto', 'Start in auto permission mode.', false) .addOption( @@ -71,6 +74,14 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--add-dir ', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); @@ -78,11 +89,14 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); + registerVisCommand(program); registerMigrateCommand(program, onMigrate); program .command('upgrade') + .alias('update') .description('Upgrade Kimi Code to the latest version.') .action(async () => { await onUpgrade(); @@ -111,7 +125,7 @@ export function createProgram( const opts: CLIOptions = { session: sessionValue, - continue: raw['continue'] as boolean, + continue: raw['continue'] === true || raw['C'] === true, yolo: yoloValue, auto: autoValue, plan: raw['plan'] as boolean, @@ -119,6 +133,7 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + addDirs: raw['addDir'] as string[], }; onMain(opts); diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts index 5ab0cfe85..57f223ad4 100644 --- a/apps/kimi-code/src/cli/goal-prompt.ts +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/; * Parses a headless prompt into a goal-create request, or `undefined` when the * prompt is not a `/goal` create command (so the caller runs it as a normal * prompt). Non-create goal subcommands are not supported headless and fall - * through to normal prompt handling. + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. */ export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { const trimmed = prompt.trim(); if (!GOAL_PREFIX.test(trimmed)) return undefined; const args = trimmed.replace(/^\/goal/, '').trim(); const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } if (parsed.kind !== 'create') return undefined; return { objective: parsed.objective, replace: parsed.replace }; } diff --git a/apps/kimi-code/src/cli/headless-exit.ts b/apps/kimi-code/src/cli/headless-exit.ts new file mode 100644 index 000000000..180572844 --- /dev/null +++ b/apps/kimi-code/src/cli/headless-exit.ts @@ -0,0 +1,96 @@ +import type { Writable } from 'node:stream'; + +import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app'; + +/** Minimal process surface needed to force a headless run to terminate. */ +export interface ExitableProcess { + exit(code?: number): void; +} + +/** + * Schedule a best-effort force-exit for a completed headless (`kimi -p`) run. + * + * Print mode does not call `process.exit()`; it relies on the Node event loop + * draining once the run is done. If a stray ref'd handle survives shutdown — a + * lingering socket (e.g. a connection blackholed by a restrictive firewall, or + * an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose + * pipes stay open — the loop never empties and the process hangs until an + * external timeout kills it. + * + * This arms an **unref'd** fallback timer: a healthy run drains and exits + * naturally before it fires (so behaviour is unchanged), and the timer itself + * never keeps the loop alive. It only force-exits a run whose loop is already + * wedged. The exit code is read lazily at fire time so callers may set + * `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal + * status to a non-zero code). + * + * Returns the timer handle so callers/tests can `clearTimeout` it. + */ +export function scheduleHeadlessForceExit( + proc: ExitableProcess, + getExitCode: () => number, + graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS, +): NodeJS.Timeout { + const timer = setTimeout(() => { + proc.exit(getExitCode()); + }, graceMs); + timer.unref?.(); + return timer; +} + +/** Resolve once a stream's currently-buffered writes have flushed to its sink. */ +function flushStream(stream: Writable): Promise { + return new Promise((resolve) => { + try { + // An empty write's callback fires after all previously-queued writes have + // been flushed (writes are ordered), which is the documented way to know a + // stream's buffer has drained. + stream.write('', () => resolve()); + } catch { + resolve(); + } + }); +} + +/** + * Wait for buffered output on the given streams to flush, bounded by `timeoutMs`. + * + * A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the + * pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any + * force-exit prevents truncating output from an otherwise-successful run. The + * wait is bounded so a permanently-stuck consumer can't re-introduce the hang. + */ +export async function drainStdio( + streams: readonly Writable[], + timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }); + try { + await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** + * Finalize a completed headless run: flush stdio, then arm the force-exit + * backstop. + * + * Draining first means in-flight legitimate output is fully written before the + * backstop can fire, and — since drained stdio no longer holds the loop — only a + * genuinely leaked handle can keep it alive afterwards, which is exactly what + * the backstop is for. + */ +export async function finalizeHeadlessRun( + proc: ExitableProcess, + streams: readonly Writable[], + getExitCode: () => number, + options: { drainTimeoutMs?: number; graceMs?: number } = {}, +): Promise { + await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS); + scheduleHeadlessForceExit(proc, getExitCode, options.graceMs); +} diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 5b1c50699..2e0c33a42 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -11,6 +11,7 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + addDirs?: string[]; } export interface ValidatedOptions { @@ -55,14 +56,5 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) { - throw new OptionConflictError('Cannot combine --yolo with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) { - throw new OptionConflictError('Cannot combine --auto with --continue or --session.'); - } - if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) { - throw new OptionConflictError('Cannot combine --plan with --continue or --session.'); - } return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index d151c2824..bcce14136 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -19,7 +19,7 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import { resolve } from 'pathe'; -import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; import type { CLIOptions, PromptOutputFormat } from './options'; import { @@ -32,6 +32,47 @@ import { import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; +/** + * Await `promise`, but stop waiting after `timeoutMs`. + * + * The timeout only bounds how long we WAIT — it does not change the outcome: + * - if `promise` settles first, its result is propagated (a rejection throws), + * so a cleanup step that actually fails in time still surfaces; + * - if the timeout wins, we resolve (give up waiting) and swallow the abandoned + * promise's eventual late rejection so it can't surface as an unhandled + * rejection. + * + * Used to bound shutdown so a wedged cleanup step can't keep a completed + * headless run alive, without silently swallowing a cleanup that fails fast. The + * timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g. + * telemetry's retry backoff when the network is blocked) can't drain the event + * loop and exit 0 before the rejection propagates — the timer keeps the loop + * alive until it fires, then gives the rejection a chance to surface. A wedged + * cleanup is still bounded by `timeoutMs`, so this can't hang the run forever. + */ +async function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + // Attach the catch eagerly (synchronously) so `promise` is always consumed and + // a late rejection can never become an unhandled rejection. Before the timeout + // wins, the handler rethrows so a real cleanup failure still propagates. + const guarded = promise.catch((error: unknown) => { + if (timedOut) return; + throw error; + }); + const timedOutSignal = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + }); + try { + await Promise.race([guarded, timedOutSignal]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + interface PromptOutput { readonly columns?: number | undefined; write(chunk: string): boolean; @@ -78,11 +119,12 @@ 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 }, }); log.info('kimi-code starting', { version, @@ -95,7 +137,7 @@ export async function runPrompt( let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; const cleanupPromptRun = async (): Promise => { - cleanupPromise ??= (async () => { + const pending = (cleanupPromise ??= (async () => { removeTerminationCleanup?.(); setCrashPhase('shutdown'); try { @@ -104,15 +146,23 @@ export async function runPrompt( await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); } - })(); - await cleanupPromise; + })()); + // Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP + // shutdown, or a connection blackholed by a restrictive firewall) cannot + // keep a completed headless run alive forever. The cleanup keeps running in + // the background if it overruns; the caller (`kimi -p`) force-exits shortly + // after, so any straggling work is torn down with the process. + await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); }; removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); try { await harness.ensureConfigFile(); const config = await harness.getConfig(); - const { session, resumed, restorePermission, telemetryModel, goalModel } = + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + stderr.write(`Warning: ${warning}\n`); + } + const { session, restorePermission, telemetryModel, goalModel } = await resolvePromptSession( harness, opts, @@ -132,16 +182,10 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); - withTelemetryContext({ sessionId: session.id }).track('started', { - resumed, - yolo: false, - plan: false, - afk: true, - }); - const outputFormat = opts.outputFormat ?? 'text'; // Headless goal mode: `kimi -p "/goal "`. The goal driver keeps // the turn-run alive across continuation turns, so the normal prompt-turn @@ -156,7 +200,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(); @@ -190,7 +234,7 @@ async function runHeadlessGoal( try { // The objective is sent as the normal prompt; goal continuation keeps the // turn alive until a terminal state is reached. - await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr); + await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr, true); } finally { unsubscribeGoalEvents(); const snapshot = completedSnapshot ?? (await session.getGoal()).goal; @@ -240,7 +284,10 @@ async function resolvePromptSession( `Session "${opts.session}" was created under a different directory.`, ); } - const session = await harness.resumeSession({ id: opts.session }); + const session = await harness.resumeSession({ + id: opts.session, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -264,7 +311,10 @@ async function resolvePromptSession( const sessions = await harness.listSessions({ workDir }); const previous = sessions[0]; if (previous !== undefined) { - const session = await harness.resumeSession({ id: previous.id }); + const session = await harness.resumeSession({ + id: previous.id, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -287,7 +337,13 @@ async function resolvePromptSession( } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await harness.createSession({ workDir, model, permission: 'auto' }); + const session = await harness.createSession({ + workDir, + model, + permission: 'auto', + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + drainAgentTasksOnStop: true, + }); installHeadlessHandlers(session); return { session, @@ -353,16 +409,21 @@ function installPromptTerminationCleanup( }; const onSigint = () => exitAfterCleanup('SIGINT'); const onSigterm = () => exitAfterCleanup('SIGTERM'); + const onSighup = () => exitAfterCleanup('SIGHUP'); promptProcess.once('SIGINT', onSigint); promptProcess.once('SIGTERM', onSigterm); + promptProcess.once('SIGHUP', onSighup); return () => { promptProcess.off('SIGINT', onSigint); promptProcess.off('SIGTERM', onSigterm); + promptProcess.off('SIGHUP', onSighup); }; } function signalExitCode(signal: NodeJS.Signals): number { - return signal === 'SIGINT' ? 130 : 143; + if (signal === 'SIGINT') return 130; + if (signal === 'SIGHUP') return 129; + return 143; } function runPromptTurn( @@ -371,9 +432,11 @@ function runPromptTurn( outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, + waitForGoalTerminal = false, ): Promise { let activeTurnId: number | undefined; let activeAgentId: string | undefined; + let latestStartedTurnId: number | undefined; const outputWriter = outputFormat === 'stream-json' ? new PromptJsonWriter(stdout) @@ -408,6 +471,18 @@ function runPromptTurn( } activeTurnId = event.turnId; activeAgentId = event.agentId; + latestStartedTurnId = event.turnId; + return; + } + if ( + waitForGoalTerminal && + event.type === 'goal.updated' && + event.agentId === PROMPT_MAIN_AGENT_ID && + activeTurnId === undefined && + event.snapshot !== null && + event.snapshot.status !== 'active' + ) { + void finishCompletedTurn(); return; } if ( @@ -426,6 +501,7 @@ function runPromptTurn( return; case 'turn.step.retrying': outputWriter.discardAssistant(); + outputWriter.writeRetrying(event); return; case 'assistant.delta': outputWriter.writeAssistantDelta(event.delta); @@ -454,7 +530,29 @@ function runPromptTurn( return; case 'turn.ended': if (event.reason === 'completed') { - finish(); + outputWriter.flushAssistant(); + if (waitForGoalTerminal) { + const completedTurnId = event.turnId; + activeTurnId = undefined; + activeAgentId = undefined; + void (async () => { + try { + const { goal } = await session.getGoal(); + if ( + activeTurnId !== undefined || + latestStartedTurnId !== completedTurnId + ) { + return; + } + if (goal?.status === 'active') return; + await finishCompletedTurn(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + })(); + return; + } + void finishCompletedTurn(); return; } finish(new Error(formatTurnEndedFailure(event))); @@ -487,6 +585,20 @@ function runPromptTurn( session.prompt(prompt).catch((error: unknown) => { finish(error instanceof Error ? error : new Error(String(error))); }); + + async function finishCompletedTurn(): Promise { + // Flush the buffered assistant message before draining background tasks: + // in stream-json mode the final message is only emitted by finish(), so a + // long background wait would otherwise withhold the main turn's result + // until the drain settles. + outputWriter.flushAssistant(); + try { + await session.waitForBackgroundTasksOnPrint(); + } catch (error) { + log.warn('waitForBackgroundTasksOnPrint failed', { error }); + } + finish(); + } }); } @@ -501,6 +613,7 @@ interface PromptTurnWriter { argumentsPart: string | undefined, ): void; writeToolResult(toolCallId: string, output: unknown): void; + writeRetrying(event: Extract): void; flushAssistant(): void; discardAssistant(): void; finish(): void; @@ -537,7 +650,14 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeToolResult(): void {} - flushAssistant(): void {} + // Text `-p` keeps retries silent: only the failed attempt's partial assistant + // text is discarded (handled by the caller). No human-readable retry line is + // emitted, matching the prior behavior. + writeRetrying(): void {} + + flushAssistant(): void { + this.assistantWriter.finish(); + } discardAssistant(): void {} @@ -576,6 +696,18 @@ interface PromptJsonResumeMetaMessage { content: string; } +interface PromptJsonRetryMetaMessage { + role: 'meta'; + type: 'turn.step.retrying'; + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; +} + function writeResumeHint( sessionId: string, outputFormat: PromptOutputFormat, @@ -674,6 +806,24 @@ class PromptJsonWriter implements PromptTurnWriter { this.toolCalls.length = 0; } + writeRetrying(event: Extract): void { + // Emit a machine-readable meta line so stream-json consumers can observe + // provider retries. The failed attempt's partial assistant text was already + // discarded by the caller, so no half-formed assistant message leaks. + const message: PromptJsonRetryMetaMessage = { + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }; + this.writeJsonLine(message); + } + finish(): void { this.flushAssistant(); } @@ -693,7 +843,9 @@ class PromptJsonWriter implements PromptTurnWriter { return toolCall; } - private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void { + private writeJsonLine( + message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage, + ): void { this.stdout.write(`${JSON.stringify(message)}\n`); } } @@ -793,5 +945,8 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } { function formatTurnEndedFailure(event: Extract): string { if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; + if (event.reason === 'filtered') { + return 'Provider safety policy blocked the response.'; + } return `Prompt turn ended with reason: ${event.reason}`; } diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 21e23602b..caeae907c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,7 +1,13 @@ -import { execSync } from 'node:child_process'; +import { execSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { + createKimiHarness, + log, + type KimiHarness, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; import { setCrashPhase, setTelemetryContext, @@ -9,12 +15,6 @@ import { track, withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { - createKimiHarness, - log, - type KimiHarness, - type TelemetryClient, -} from '@moonshot-ai/kimi-code-sdk'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; import { detectPendingMigration } from '#/migration/index'; @@ -22,7 +22,10 @@ import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; -import { detectTerminalTheme } from '#/tui/theme/detect'; +import { currentTheme, getColorPalette } from '#/tui/theme'; +import { combineStartupNotice } from '#/tui/utils/startup'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; @@ -45,9 +48,9 @@ export async function runShell( configWarning = error.message; } - // Resolve `theme = "auto"` against the live terminal once, before pi-tui - // grabs stdin. Explicit `dark` / `light` skip detection. - const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme; + // Initialise the global Theme singleton before pi-tui grabs stdin. + const palette = await getColorPalette(tuiConfig.theme); + currentTheme.setPalette(palette); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -59,17 +62,19 @@ export async function runShell( const harness = createKimiHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), + skillDirs: opts.skillsDirs, 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, }); }, + sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, }); log.info('kimi-code starting', { version, @@ -91,14 +96,17 @@ export async function runShell( return; } const config = await harness.getConfig(); + for (const warning of (await harness.getConfigDiagnostics()).warnings) { + configWarning = combineStartupNotice(configWarning, warning); + } const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { cliOptions: opts, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, workDir, startupNotice: configWarning, - resolvedTheme, migrationPlan, migrateOnly: runOptions.migrateOnly, }); @@ -112,7 +120,6 @@ export async function runShell( }); setCrashPhase('runtime'); - const resumed = opts.continue || opts.session !== undefined; const trackLifecycleForSession = ( sessionId: string, event: string, @@ -128,35 +135,85 @@ export async function runShell( trackLifecycleForSession(tui.getCurrentSessionId(), event, properties); }; + let savedStty: string | undefined; + try { + // stty operates on the terminal behind stdin, so stdin must be the TTY — + // piping /dev/null (ignore) makes stty fail with "not a tty". + const saved = execSync('stty -g', { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'ignore'], + }); + savedStty = typeof saved === 'string' ? saved.trim() : undefined; + execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + } catch { + /* ignore */ + } + const restoreStty = (): void => { + if (savedStty === undefined) return; + const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0); + if (args.length === 0) return; + spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] }); + }; + + // If we crash without going through KimiTUI.stop(), the terminal is left in + // raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore + // both before exiting so the user's shell is usable afterwards. + const emergencyExit = (exitCode: number): void => { + restoreTerminalModes(); + restoreStty(); + process.exit(exitCode); + }; + const onUncaughtException = (error: unknown): void => { + try { + log.error('uncaughtException, restoring terminal and exiting', { error: String(error) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + const onUnhandledRejection = (reason: unknown): void => { + try { + log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + process.on('uncaughtException', onUncaughtException); + process.on('unhandledRejection', onUnhandledRejection); + // Remove the crash handlers once the TUI exits cleanly so repeated runShell() + // calls in the same process (e.g. tests) don't accumulate process listeners. + const removeCrashHandlers = (): void => { + process.off('uncaughtException', onUncaughtException); + process.off('unhandledRejection', onUnhandledRejection); + }; + tui.onExit = async (exitCode = 0) => { 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`); + const hints: string[] = []; if (sessionId !== '' && hasContent) { - process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`); + hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`); } + if (tui.exitOpenUrl !== undefined) { + hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`); + } + if (hints.length > 0) { + process.stderr.write(`\n${hints.join('\n')}\n`); + } + removeCrashHandlers(); + restoreStty(); process.exit(exitCode); }; - try { - execSync('stty -ixon', { stdio: 'ignore' }); - } catch { - /* ignore */ - } try { const initStartedAt = Date.now(); await tui.start(); const initMs = Date.now() - initStartedAt; - trackLifecycle('started', { - resumed, - yolo: opts.yolo, - auto: opts.auto, - plan: opts.plan, - afk: false, - }); const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); trackLifecycleForSession(startupSessionId, 'startup_perf', { @@ -166,8 +223,9 @@ export async function runShell( mcp_ms: mcpMs, }); } catch (error) { + removeCrashHandlers(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts index 3c1a123d4..005dc1729 100644 --- a/apps/kimi-code/src/cli/sub/login-flow.ts +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -17,18 +17,17 @@ export async function runLoginFlow(): Promise { uiMode: 'cli', }); const controller = new AbortController(); - process.once('SIGINT', () => controller.abort()); + process.once('SIGINT', () => { + controller.abort(); + }); try { const result = await harness.auth.login(undefined, { signal: controller.signal, onDeviceCode: (data) => { const url = data.verificationUriComplete || data.verificationUri; - // Best-effort: try to open the user's default browser at the - // pre-baked URL (which already embeds the user code). Print the - // URL + code as a fallback for headless boxes / when openUrl - // silently fails (it `execFile`s `open`/`xdg-open`/`cmd start` - // with no error handling — see `utils/open-url.ts`). - openUrl(url); + // Print the manual fallback before attempting to open the user's + // browser so headless/browser-opener failures never hide the URL + // and code needed to complete login. process.stderr.write( [ '', @@ -43,15 +42,20 @@ export async function runLoginFlow(): Promise { .filter((line): line is string => line !== undefined) .join('\n'), ); + try { + openUrl(url); + } catch { + // Best effort only: the manual fallback has already been printed. + } }, }); process.stderr.write(`Logged in to ${result.providerName}.\n`); process.exit(0); - } catch (err) { + } catch (error) { if (controller.signal.aborted) { process.stderr.write('Login cancelled.\n'); } else { - const message = err instanceof Error ? err.message : String(err); + const message = error instanceof Error ? error.message : String(error); process.stderr.write(`Login failed: ${message}\n`); } process.exit(1); diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 38cb75521..8ed4546aa 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -7,8 +7,9 @@ * * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the * TUI does; the next launch's `refreshAllProviderModels` - * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}` - * and re-fetches the model list, so periodic refresh is automatic. + * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries + * available API-key candidates, and re-fetches the model list, so periodic + * refresh is automatic. */ import { @@ -339,7 +340,7 @@ export async function handleCatalogAdd( // already-configured provider would lose the user's previously-set default // even when `--default-model` is not supplied. const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; + const previousThinking = config.thinking; if (config.providers[providerId] !== undefined) { config = await harness.removeProvider(providerId); @@ -347,7 +348,7 @@ export async function handleCatalogAdd( const baseUrl = catalogBaseUrl(entry, wire); // `applyCatalogProvider` always overwrites both `defaultModel` and - // `defaultThinking`. The values we pass here are temporary; we restore + // `[thinking]`. The values we pass here are temporary; we restore // a consistent state in the post-apply block below. applyCatalogProvider(config, { providerId, @@ -372,18 +373,18 @@ export async function handleCatalogAdd( config.defaultModel = stillResolves ? previousDefaultModel : undefined; } - // Always restore `defaultThinking` from what was there before — including - // `undefined`. Persisting `false` when the user never set it would make - // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat - // it as an explicit "off" request and silently disable thinking, even - // for thinking-capable models. - config.defaultThinking = previousDefaultThinking; + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; await harness.setConfig({ providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); const displayName = entry.name ?? providerId; @@ -410,13 +411,26 @@ export function registerProviderCommand(parent: Command, deps?: Partial Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + provider .command('add ') .description('Import every provider listed in a custom registry (api.json).') .option('--api-key ', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.') .action(async (url: string, options: { apiKey?: string }) => { const resolved = resolveDeps(deps); - await handleProviderAdd(resolved, url, { apiKey: options.apiKey }); + await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey })); }); provider @@ -424,7 +438,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleProviderRemove(resolved, providerId); + await runAction(resolved, () => handleProviderRemove(resolved, providerId)); }); provider @@ -433,7 +447,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleProviderList(resolved, { json: options.json === true }); + await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true })); }); const catalog = provider @@ -452,11 +466,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleCatalogList(resolved, providerId, { - json: options.json === true, - ...(options.filter === undefined ? {} : { filter: options.filter }), - ...(options.url === undefined ? {} : { url: options.url }), - }); + await runAction(resolved, () => + handleCatalogList(resolved, providerId, { + json: options.json === true, + ...(options.filter === undefined ? {} : { filter: options.filter }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); }, ); @@ -472,11 +488,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial { const resolved = resolveDeps(deps); - await handleCatalogAdd(resolved, providerId, { - ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), - ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), - ...(options.url === undefined ? {} : { url: options.url }), - }); + await runAction(resolved, () => + handleCatalogAdd(resolved, providerId, { + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + }), + ); }, ); } diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts new file mode 100644 index 000000000..0c6233fe8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -0,0 +1,85 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `server run` ready banner and `server rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts new file mode 100644 index 000000000..cc633934c --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -0,0 +1,412 @@ +/** + * `kimi web` daemon orchestration — parent (spawner) side. + * + * Ensures a single background server daemon exists for this device, then + * returns its origin so the caller can open the web UI. The flow: + * + * 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it + * (wait for it to be healthy) — never spawn a second one. + * 2. Otherwise pick a free port (preferred port when available, else an + * OS-assigned one) and spawn `kimi server run --daemon` as a detached + * child whose stdio is redirected to the server log. + * 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's + * that won the lock) is healthy, then return its origin. + * + * The child side (`startServerDaemon`) lives in `./run.ts` next to the + * foreground runner so it can share the same bootstrap helpers. + */ + +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'; + +import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/server'; + +import { + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, + isServerHealthy, + serverOrigin, + waitForServerHealthy, +} from './shared'; + +const SERVER_LOG_FILENAME = 'server.log'; + +/** How long to wait for an already-running daemon to answer `/healthz`. */ +const REUSE_HEALTH_TIMEOUT_MS = 15_000; +/** How long to wait for a freshly-spawned daemon to come up. */ +const SPAWN_TIMEOUT_MS = 20_000; +/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */ +const POLL_INTERVAL_MS = 200; +/** Default log level for a daemon spawned without an explicit `--log-level`. */ +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; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Keep the daemon alive instead of idle-killing it (`--keep-alive`). */ + keepAlive?: 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). */ +export function daemonLogPath(): string { + return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME); +} + +export function lockConnectHost(lock: LockContents): string { + 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). */ +function canBind(host: string, port: number): Promise { + return new Promise((resolvePromise) => { + const probe = createServer(); + probe.once('error', () => resolvePromise(false)); + probe.listen({ host, port }, () => { + probe.close(() => resolvePromise(true)); + }); + }); +} + +/** Ask the OS for an ephemeral free port on `host`. */ +function getFreePort(host: string): Promise { + return new Promise((resolvePromise, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen({ host, port: 0 }, () => { + const address = probe.address(); + if (address === null || typeof address === 'string') { + probe.close(() => reject(new Error('failed to allocate a free port'))); + return; + } + const { port } = address; + probe.close(() => resolvePromise(port)); + }); + }); +} + +/** + * How many consecutive `preferred + n` ports to probe before giving up and + * asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's + * own bind retry so the spawner and the daemon agree on the policy. + */ +export const DAEMON_PORT_SCAN_LIMIT = 100; + +/** + * Pick a port for a new daemon: prefer `preferred` when it is free, otherwise + * walk `preferred + 1`, `+ 2`, … upward and take the first free one. Only when + * the whole scan window is saturated do we fall back to an OS-assigned free + * port. + * + * Reusing an already-live daemon is handled by `ensureDaemon` before this runs, + * so a busy port here is held by a third-party process — bumping by one (rather + * than jumping to a random ephemeral port) keeps the URL predictable, matching + * the server's own "port busy ⇒ +1" bind retry. + */ +export async function resolveDaemonPort( + host: string = DEFAULT_SERVER_HOST, + preferred: number = DEFAULT_SERVER_PORT, +): Promise { + for ( + let candidate = preferred; + candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535; + candidate++ + ) { + if (await canBind(host, candidate)) return candidate; + } + return getFreePort(host); +} + +interface NodeSeaModule { + isSea(): boolean; +} + +const nodeRequire = createRequire(import.meta.url); +let cachedSea: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (cachedSea !== undefined) return cachedSea; + try { + cachedSea = nodeRequire('node:sea') as NodeSeaModule; + } catch { + cachedSea = null; + } + return cachedSea; +} + +/** True when running as a compiled single-executable (SEA / native) binary. */ +function detectSea(): boolean { + const sea = loadSeaModule(); + if (sea === null) return false; + try { + return sea.isSea(); + } catch { + return false; + } +} + +/** + * Absolute path to the CLI entry that should be re-execed to run the daemon. + * Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`: + * when the CLI is a compiled single binary, `argv[1]` is the invoked command + * name (e.g. `kimi`) or the first user argument — never a script path — so we + * must re-exec `process.execPath` itself. + */ +export function resolveDaemonProgram( + argv: readonly string[] = process.argv, + cwd: string = process.cwd(), + execPath: string = process.execPath, + isSea: boolean = detectSea(), +): string { + // In a SEA binary `argv[1]` is not a script path, so resolving it against + // `cwd` would produce a bogus path (e.g. `/kimi`) and crash the spawn + // with ENOENT. Always re-exec the binary itself. + if (isSea) return execPath; + const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); + return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); +} + +interface SpawnDaemonChildOptions { + host?: string; + port: number; + logLevel: string; + debugEndpoints?: boolean; + insecureNoTls?: boolean; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; + dangerousBypassAuth?: boolean; + keepAlive?: boolean; + allowedHosts?: readonly string[]; + idleGraceMs?: number; +} + +export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { + const program = resolveDaemonProgram(); + const logPath = daemonLogPath(); + const logDir = dirname(logPath); + mkdirSync(logDir, { recursive: true }); + const args = [ + 'server', + 'run', + '--daemon', + '--port', + String(options.port), + '--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.dangerousBypassAuth === true) { + args.push('--dangerous-bypass-auth'); + } + if (options.keepAlive === true) { + args.push('--keep-alive'); + } + 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(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 + // launched from (notably blocking its deletion on Windows). + cwd: logDir, + stdio: ['ignore', logFd, logFd], + }); + child.once('error', (error) => { + // A spawn failure (e.g. ENOENT) surfaces asynchronously on the child, + // not as a thrown error. Without a listener Node would crash the parent + // with an unhandled 'error' event; record it instead and let the polling + // loop in `ensureDaemon` report the timeout. + try { + appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`); + } catch { + // Best-effort; the log directory may already be gone. + } + }); + child.unref(); + return child; + } finally { + // `spawn` dups the fd into the child; the parent must not keep it open. + closeSync(logFd); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} + +/** + * Ensure a daemon is running and return its origin. Non-blocking for the + * caller beyond the short health wait — the server itself keeps running in a + * detached process after this returns. + */ +export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; + const preferred = options.port ?? DEFAULT_SERVER_PORT; + const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; + + // 1. Reuse an already-live daemon if one holds the lock. + const existing = getLiveLock(); + if (existing) { + const origin = serverOrigin(lockConnectHost(existing), existing.port); + if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) { + 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 + // reconnect below; if it died, stale takeover lets our child claim it. + } + + // 2. No reusable daemon — pick a free port and spawn one detached. + const port = await resolveDaemonPort(host, preferred); + const child = spawnDaemonChild({ + host, + port, + logLevel, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, + 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) { + const live = getLiveLock(); + if (live) { + const origin = serverOrigin(lockConnectHost(live), live.port); + if (await isServerHealthy(origin, 500)) { + 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.\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 ''; + } +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts new file mode 100644 index 000000000..b8e9a5ffa --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -0,0 +1,49 @@ +/** + * `kimi server` parent command. Mounts: + * - `server run` (background daemon by default; `--foreground` to attach; the + * detached daemon child runs the same command with `--daemon`) + * + * The OS service-manager subcommands (`install/uninstall/start/stop/restart/ + * status`) are temporarily NOT registered — see the commented + * `addLifecycleCommands(server)` below. Their implementation is preserved in + * `./lifecycle.ts` + `packages/server/src/svc/*` for later re-exposure. + * + * The top-level `kimi web` alias is registered separately via + * `registerWebAliasCommand` so it stays at the program root. + */ + +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 { + const server = program + .command('server') + .description('Run the local Kimi server (REST + WebSocket + web UI).'); + + buildRunCommand( + server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'), + { defaultOpen: false }, + ); + + registerPsCommand(server); + + 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 + // `./lifecycle.ts` + `packages/server/src/svc/*`; re-import + // `addLifecycleCommands` and call it here to re-expose. + // addLifecycleCommands(server); + + registerWebAliasCommand(program); +} + +export { registerWebAliasCommand }; diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts new file mode 100644 index 000000000..71b4f2e44 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -0,0 +1,167 @@ +/** + * `kimi server kill` — terminate the running server. + * + * Combines two independent mechanisms so the server dies even if one path + * fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; older builds or a wedged server may not answer). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL / TerminateProcess is the hard guarantee: + * it cannot be caught or ignored. + * + * The only honest failure mode is insufficient permissions (a process owned by + * another user), which surfaces as an error rather than a silent miss. + */ + +import type { Command } from 'commander'; + +import { getLiveLock, type LockContents } from '@moonshot-ai/server'; + +import { getDataDir } from '#/utils/paths'; + +import { lockConnectHost } from './daemon'; +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +export interface KillCommandDeps { + getLiveLock(): LockContents | undefined; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + now(): number; +} + +export function registerKillCommand(server: Command): void { + server + .command('kill') + .description('Stop the running Kimi server (graceful API + forced PID kill).') + .action(async () => { + try { + await handleKillCommand(DEFAULT_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleKillCommand(deps: KillCommandDeps): Promise { + const lock = deps.getLiveLock(); + if (!lock) { + deps.stdout.write('No running Kimi server.\n'); + return; + } + + const { pid } = lock; + const origin = serverOrigin(lockConnectHost(lock), lock.port); + + // 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. 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'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`); + return; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`); + return; + } + + throw new Error( + `Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +const DEFAULT_KILL_DEPS: KillCommandDeps = { + getLiveLock, + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts new file mode 100644 index 000000000..f7ff072d8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -0,0 +1,254 @@ +/** + * `kimi server install/uninstall/start/stop/restart/status`. + * + * Phase 2 lands the CLI shape; the lifecycle calls into the platform service + * manager from `@moonshot-ai/server`, which is filled in by Phase 3+. + * + * The Commander wiring here mirrors `addGatewayServiceCommands` from + * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. + */ + +import type { Command } from 'commander'; + +import { + ServiceUnavailableError, + ServiceUnsupportedError, + resolveServiceManager, + type InstallArgs, + type ServiceManager, + type ServiceStatus, +} from '@moonshot-ai/server'; + +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { + DEFAULT_LOG_LEVEL, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + LOCAL_SERVER_HOST, + parseLogLevel, + parsePort, + serverOrigin, + VALID_LOG_LEVELS, +} from './shared'; + +export interface InstallCliOptions { + port?: string; + logLevel?: string; + force?: boolean; + open?: boolean; + json?: boolean; +} + +export interface JsonCliOptions { + json?: boolean; +} + +export interface LifecycleCommandDeps { + resolveManager(): ServiceManager; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +const DEFAULT_DEPS: LifecycleCommandDeps = { + resolveManager: resolveServiceManager, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; + +/** Mount install/uninstall/start/stop/restart/status under a parent command. */ +export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { + parent + .command('install') + .description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).') + .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) + .option( + '--log-level ', + `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, + DEFAULT_LOG_LEVEL, + ) + .option('--force', 'Reinstall and overwrite if already installed', false) + .option('--no-open', 'Do not open the web UI after install.', true) + .option('--json', 'Output JSON', false) + .action(async (opts: InstallCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const args: InstallArgs = { + host: DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel), + force: opts.force === true, + }; + const result = await mgr.install(args); + const status = await readStatus(mgr); + const enriched = withStatusDetails({ + ok: true, + action: 'install', + status: result.status, + plistPath: result.plistPath, + unitPath: result.unitPath, + taskName: result.taskName, + message: result.message, + }, status, args); + if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { + deps.openUrl(enriched.url); + } + return enriched; + }); + }); + + parent + .command('uninstall') + .description('Uninstall the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.uninstall(); + return { ok: result.ok, action: 'uninstall', message: result.message }; + }); + }); + + parent + .command('start') + .description('Start the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.start(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); + }); + }); + + parent + .command('stop') + .description('Stop the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.stop(); + return { ok: result.ok, action: 'stop', message: result.message }; + }); + }); + + parent + .command('restart') + .description('Restart the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.restart(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); + }); + }); + + parent + .command('status') + .description('Show Kimi server service status and connectivity.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const status: ServiceStatus = await mgr.status(); + return withStatusDetails({ ok: true, action: 'status', ...status }, status); + }); + }); +} + +async function runLifecycle( + deps: LifecycleCommandDeps, + json: boolean, + body: (mgr: ServiceManager) => Promise>, +): Promise { + try { + const mgr = deps.resolveManager(); + const result = await body(mgr); + if (json) { + deps.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + deps.stdout.write(formatHuman(result)); + } catch (error) { + if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { + const payload = { + ok: false, + action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', + platform: error.platform, + message: error.message, + }; + if (json) { + deps.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + deps.stderr.write(`${error.message}\n`); + } + process.exit(2); + return; + } + if (json) { + deps.stdout.write( + `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, + ); + } else { + deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + } + process.exit(1); + } +} + +function formatHuman(result: Record): string { + const rawAction = result['action']; + const action = typeof rawAction === 'string' ? rawAction : 'action'; + const rawMessage = result['message']; + const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; + const lines = [`${action}${message}`]; + + const url = result['url']; + if (typeof url === 'string') lines.push(`URL: ${url}`); + + const running = result['running']; + if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); + + const logPath = result['logPath']; + if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); + + const notes = result['notes']; + if (Array.isArray(notes)) { + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); + } + } + + return `${lines.join('\n')}\n`; +} + +async function readStatus(mgr: ServiceManager): Promise { + try { + return await mgr.status(); + } catch { + return undefined; + } +} + +function withStatusDetails( + result: Record, + status: ServiceStatus | undefined, + fallback?: { host: string; port: number }, +): Record & { url?: string; running?: boolean } { + const host = status?.host ?? fallback?.host; + const port = status?.port ?? fallback?.port; + const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; + return { + ...result, + url, + running: status?.running, + host, + port, + logPath: status?.logPath, + notes: status?.notes, + }; +} + +function formatServiceUrl(host: string, port: number): string { + return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port); +} diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/server/networks.ts new file mode 100644 index 000000000..39ca7d084 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts new file mode 100644 index 000000000..6e17d71c0 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -0,0 +1,148 @@ +/** + * `kimi server ps` — list clients currently connected to the running server. + * + * Talks to the running server over HTTP (`GET /api/v1/connections`) using the + * single-instance lock (`~/.kimi-code/server/lock`) to discover its origin — + * the same way `kimi web` locates the daemon. + */ + +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { getLiveLock } from '@moonshot-ai/server'; + +import { getDataDir } from '#/utils/paths'; + +import { lockConnectHost } from './daemon'; +import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; + +/** Wire shape of a single connection returned by `GET /api/v1/connections`. */ +interface ConnectionInfo { + id: string; + connected_at: string; + remote_address: string | null; + user_agent: string | null; + has_client_hello: boolean; + subscriptions: string[]; +} + +interface ConnectionsEnvelope { + code: number; + msg: string; + data?: { connections?: ConnectionInfo[] }; +} + +const HEALTH_TIMEOUT_MS = 1500; +const FETCH_TIMEOUT_MS = 5000; +const USER_AGENT_MAX_WIDTH = 40; + +export function registerPsCommand(server: Command): void { + server + .command('ps') + .description('List clients currently connected to the running Kimi server.') + .option('--json', 'Print the raw connection list as JSON.') + .action(async (opts: { json?: boolean }) => { + try { + await handlePsCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +async function handlePsCommand(opts: { json?: boolean }): Promise { + const lock = getLiveLock(); + if (!lock) { + throw new Error( + 'No running Kimi server. Start one with `kimi server run` or `kimi web`.', + ); + } + + const origin = serverOrigin(lockConnectHost(lock), lock.port); + if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { + throw new Error(`Kimi server at ${origin} is not responding.`); + } + + // 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`); + return; + } + process.stdout.write(formatTable(connections)); +} + +async function fetchConnections(origin: string, token: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + try { + const res = await fetch(`${origin}/api/v1/connections`, { + headers: authHeaders(token), + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`); + } + const body = (await res.json()) as ConnectionsEnvelope; + if (body.code !== 0) { + throw new Error(`Failed to list clients: ${body.msg}`); + } + return body.data?.connections ?? []; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Timed out listing clients from ${origin}.`); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function formatTable(connections: ConnectionInfo[]): string { + if (connections.length === 0) { + return 'No active clients.\n'; + } + + const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO']; + const rows = connections.map((c) => [ + c.id, + formatAge(c.connected_at), + c.remote_address ?? '-', + truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH), + String(c.subscriptions.length), + c.has_client_hello ? 'yes' : 'no', + ]); + + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); + const formatRow = (cells: string[]): string => + cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' '); + + const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)]; + return `${lines.join('\n')}\n`; +} + +function formatAge(iso: string): string { + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return '-'; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${String(minutes)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h`; + const days = Math.floor(hours / 24); + return `${String(days)}d`; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + if (max <= 1) return value.slice(0, max); + return `${value.slice(0, max - 1)}…`; +} diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts new file mode 100644 index 000000000..8f9a47970 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -0,0 +1,57 @@ +/** + * `kimi server rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveLock, rotateServerToken } from '@moonshot-ai/server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; +import { DEFAULT_SERVER_HOST } from './shared'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the lock; otherwise there is nothing to connect to yet. + const lock = getLiveLock(); + if (lock !== undefined) { + const host = lock.host ?? DEFAULT_SERVER_HOST; + for (const { label, url: href } of accessUrlLines(host, lock.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts new file mode 100644 index 000000000..19984a4f5 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -0,0 +1,559 @@ +/** + * `kimi server run` — starts the local server. + * + * By default this ensures a single background daemon is running (spawning a + * detached `kimi server run --daemon` child when needed) and returns once it is + * healthy. Pass `--foreground` to run the server in-process and keep this + * terminal attached until SIGINT/SIGTERM. OS-managed background operation + * (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`. + * + * `kimi web` is an alias of this command with `--open` defaulted to `true`, + * registered in `./web-alias.ts`. + */ + +import { join } from 'node:path'; + +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 } 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 { + 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'; + +export interface RunCliOptions extends ServerCliOptions { + open?: boolean; + /** Run the server in-process instead of spawning a background daemon. */ + foreground?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void; +} + +export interface RunCommandDeps { + startServerBackground(options: ParsedServerOptions): Promise<{ + origin: string; + /** True when an already-running daemon was reused (no new server started). */ + reused?: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + host?: string; + /** Port the running daemon is actually listening on (from the lock). */ + port?: number; + }>; + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; + stdout: Pick; + stderr: Pick; +} + +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + +/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ +export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--keep-alive', + 'Keep the server running instead of exiting after 60s with no connected clients. Implied automatically by --host / --allowed-host, and always on in --foreground mode.', + false, + ) + .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( + '--dangerous-bypass-auth', + 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', + false, + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--foreground', + 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).', + false, + ) + .option( + options.defaultOpen ? '--no-open' : '--open', + options.defaultOpen + ? 'Do not open the web UI in the default browser.' + : 'Open the web UI in the default browser once the server is healthy.', + options.defaultOpen, + ) + .addOption( + new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(), + ) + .addOption( + new Option( + '--idle-grace-ms ', + 'Idle-shutdown grace in ms (daemon mode, internal).', + ).hideHelp(), + ) + .action(async (opts: RunCliOptions) => { + try { + await handleRunCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleRunCommand( + opts: RunCliOptions, + deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, +): Promise { + const parsed = parseServerOptions(opts); + if (parsed.daemon) { + await startServerDaemon(parsed); + return; + } + // Foreground is always keep-alive: a server attached to the operator's + // terminal must never idle-kill itself. Background daemons respect the + // derived `--keep-alive` flag. + const runOptions: ParsedServerOptions = + opts.foreground === true ? { ...parsed, keepAlive: true } : parsed; + // 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. When auth is bypassed, + // the token is meaningless and is intentionally NOT shown or carried in the + // opened URL. + const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => { + const { origin } = result; + const host = result.host ?? parsed.host; + // When a daemon is reused, this command's flags were NOT applied to the + // already-running server. Don't trust the requested `--dangerous-bypass-auth` + // for display/open: treat the server as token-protected so we never hide a + // token the user actually needs, nor claim bypass for a server that is + // authenticating. (Probing the running server's `/meta` would give its real + // mode; we conservatively assume non-bypass on reuse.) + const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth; + const token = effectiveBypass ? undefined : 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, + dangerousBypassAuth: effectiveBypass, + }) + : formatReadyLine(origin, token, effectiveBypass); + 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(runOptions, { + onReady: (origin) => { + writeReady({ origin, reused: false, host: parsed.host }); + }, + }); + return; + } + const result = await deps.startServerBackground(runOptions); + 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` + ); +} + +function formatReadyLine( + origin: string, + token: string | undefined, + dangerousBypassAuth = false, +): string { + const notice = dangerousBypassAuth + ? `${formatDangerNoticeLines().join('\n')}\n` + : ''; + return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`; +} + +/** + * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` + * disables the bearer-token gate. Shared by the full ready banner and the + * compact one-line output so the warning always shows regardless of log level. + */ +function formatDangerNoticeLines(): string[] { + const danger = (text: string): string => chalk.hex(darkColors.error)(text); + const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); + return [ + ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, + ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, + ` ${danger(`If you are unsure, run `)}${dangerBold('kimi server kill')}${danger(' now to stop this process.')}`, + ]; +} + +/** + * `kimi server run` (non-daemon) — ensures a background daemon is running + * (spawning a detached `kimi server run --daemon` child if needed), then + * returns its origin so the caller can print the ready banner and exit. The + * server keeps running in the background after this returns. + */ +export async function startServerBackground( + options: ParsedServerOptions, +): Promise { + return ensureDaemon({ + host: options.host, + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, + allowedHosts: options.allowedHosts, + idleGraceMs: options.idleGraceMs, + }); +} + +/** + * `kimi server run --daemon` — runs the local server as a background daemon. + * + * Spawned as a detached child by {@link startServerBackground}. The process is + * expected to be detached (no controlling terminal) and self-terminates after + * the last web client disconnects and a grace period elapses. The grace timer + * is driven by the WS connection count reported through `wsGatewayOptions`. + * Resolves only via `process.exit`. + */ +export async function startServerDaemon(options: ParsedServerOptions): Promise { + return runServerInProcess(options, { daemon: true }); +} + +/** + * `kimi server run --foreground` — runs the local server in-process, attached + * to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise { + return runServerInProcess(options, { daemon: false }, hooks.onReady); +} + +/** + * Start the server in the current process and block until shutdown. Shared by + * the detached daemon (`daemon: true`, with idle-exit) and the foreground + * runner (`daemon: false`). `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + mode: { daemon: boolean }, + onReady?: (origin: string) => void, +): Promise { + const version = getVersion(); + const telemetry = initializeServerTelemetry({ version }); + + let running: RunningServer | undefined; + let stopping = false; + + // Idle auto-shutdown is only for the on-demand personal daemon. It is skipped + // in foreground mode (`mode.daemon` is false) and whenever `--keep-alive` is + // set — explicitly, or implied by `--host` / `--allowed-host`. + const idle = + mode.daemon && !options.keepAlive + ? createIdleShutdownHandler({ + graceMs: options.idleGraceMs, + onIdle: () => { + void shutdown('idle'); + }, + }) + : undefined; + + async function shutdown(reason: string): Promise { + if (stopping) return; + stopping = true; + idle?.cancel(); + running?.logger.info({ reason }, 'server shutting down'); + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + running = await startServer({ + host: options.host, + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + allowedHosts: options.allowedHosts, + webAssetsDir: serverWebAssetsDir(), + coreProcessOptions: { + identity: createKimiCodeHostIdentity(version), + telemetry, + }, + wsGatewayOptions: { + telemetry, + onConnectionCountChange: idle + ? (size) => { + idle.onConnectionCountChange(size); + } + : undefined, + }, + }); + + track('server_started', { daemon: mode.daemon }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + const readyFields = mode.daemon + ? options.keepAlive + ? { address: running.address, idleShutdown: 'disabled' as const } + : { address: running.address, idleGraceMs: options.idleGraceMs } + : { address: running.address }; + running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); + + onReady?.(running.address); + + return new Promise(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +/** + * Pure idle-shutdown state machine, exported for tests. + * + * Watches the live WS connection count and fires `onIdle` exactly once, after + * the count has dropped back to zero for `graceMs` ms *and* at least one + * client had connected since startup. A reconnect before the grace elapses + * cancels the pending exit. The initial "no clients yet" state never arms the + * timer (so a freshly-spawned daemon is not killed before anyone connects). + */ +export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): { + onConnectionCountChange(size: number): void; + cancel(): void; +} { + let timer: NodeJS.Timeout | undefined; + let seenClient = false; + + const cancel = (): void => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + + return { + onConnectionCountChange(size: number): void { + if (size > 0) { + seenClient = true; + cancel(); + return; + } + if (seenClient) { + cancel(); + timer = setTimeout(opts.onIdle, opts.graceMs); + } + }, + cancel, + }; +} + +function serverWebAssetsDir(): string { + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; + /** When true, render a red danger notice (auth is disabled). */ + dangerousBypassAuth?: boolean; +} + +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 = (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 lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, + '', + ]; + + if (opts.dangerousBypassAuth === true) { + // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone + // who can reach this port gets full session / filesystem / shell access. + lines.push(...formatDangerNoticeLines(), ''); + } + + // 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(''); + } + + // 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'); +} + +const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { + startServerBackground, + startServerForeground, + openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts new file mode 100644 index 000000000..aa2b686ce --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -0,0 +1,279 @@ +/** + * Shared helpers for `kimi server …` subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes that + * `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 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'; + +/** + * Default idle-shutdown grace for the background daemon: once the last web + * client disconnects, the daemon waits this long before exiting. Overridable + * via the internal `--idle-grace-ms` flag (used by tests). + */ +export const DEFAULT_IDLE_GRACE_MS = 60_000; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + 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; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; + /** + * Keep the server running instead of idle-killing it after 60s with no + * connected clients (`--keep-alive`). Also implied automatically by a + * non-default bind (`--host`) or a proxy/tunnel setup (`--allowed-host`), + * and always on in `--foreground` mode. Only the daemon mode consults this — + * foreground never idle-kills regardless. + */ + keepAlive: boolean; + /** Internal: run as an idle-exiting background daemon instead of foreground. */ + daemon: boolean; + /** Internal: idle-shutdown grace in ms (daemon mode only). */ + idleGraceMs: number; +} + +export interface ServerCliOptions { + 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; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; + /** Keep the server running instead of idle-killing it (`--keep-alive`). */ + keepAlive?: boolean; + /** Internal flag set by the daemon spawner (`kimi web`). */ + daemon?: boolean; + /** Internal flag set by the daemon spawner / tests. */ + idleGraceMs?: string; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + const host = parseHost(opts.host); + const allowedHosts = parseAllowedHostArgs(opts.allowedHost); + // `--keep-alive` is explicit, but also implied by a non-default bind + // (`--host`) or a proxy/tunnel setup (`--allowed-host`). Foreground mode is + // forced keep-alive later in `handleRunCommand`. + const keepAlive = + opts.keepAlive === true || host !== DEFAULT_SERVER_HOST || allowedHosts.length > 0; + return { + 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, + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts, + keepAlive, + 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); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`error: invalid --idle-grace-ms value: ${raw}`); + } + return n; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ +export async function isServerHealthy(origin: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const response = await fetch(`${origin}/api/v1/healthz`, { + signal: controller.signal, + }); + if (!response.ok) return false; + const body = (await response.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ +export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + if (await isServerHealthy(origin, 500)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } while (Date.now() < deadline); + return false; +} + +/** + * Probe `/` and confirm the bundled web UI is being served. + * + * A different build that runs on the same port serves its own bundle — opening + * a browser at that origin lands on stale code. Catching that here lets the + * caller surface a clear "stop the running server" message instead of silently + * handing the user the wrong UI. + */ +export async function ensureServerWebReady(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + try { + const response = await fetch(`${origin}/`, { + headers: { accept: 'text/html' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const body = await response.text(); + if (!body.includes('
/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer `. `homeDir` + * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer ` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/src/cli/sub/server/web-alias.ts b/apps/kimi-code/src/cli/sub/server/web-alias.ts new file mode 100644 index 000000000..eb4831890 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/web-alias.ts @@ -0,0 +1,22 @@ +/** + * `kimi web` — open the Kimi web UI. + * + * Shares the exact same code path as `kimi server run`: it is registered via + * the same `buildRunCommand` builder (and therefore the same `handleRunCommand` + * handler, the same background-daemon flow, and the same ready banner) with + * `defaultOpen` flipped to `true`. The only difference from `server run` is + * that `web` opens the browser by default. + */ + +import type { Command } from 'commander'; + +import { buildRunCommand } from './run'; + +export function registerWebAliasCommand(program: Command): void { + buildRunCommand( + program + .command('web') + .description('Open the Kimi web UI (starts a background daemon if needed).'), + { defaultOpen: true }, + ); +} diff --git a/apps/kimi-code/src/cli/sub/vis.ts b/apps/kimi-code/src/cli/sub/vis.ts new file mode 100644 index 000000000..7e6f2e1f2 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/vis.ts @@ -0,0 +1,158 @@ +/** + * `kimi vis` sub-command. + * + * CLI glue only: resolves the kimi home, starts the in-process session + * visualizer server (auto-picking a free port by default), prints the URL, + * optionally opens the browser (with an optional session deep-link), then + * waits for Ctrl-C and shuts the server down. The visualizer server itself + * lives in `@moonshot-ai/vis-server`. + */ + +import type { Command } from 'commander'; + +import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { openUrl } from '#/utils/open-url'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface StartedVisServer { + readonly port: number; + readonly host: string; + readonly url: string; + readonly close: () => Promise; +} + +export interface StartVisServerArgs { + readonly homeDir: string; + readonly port: number; + readonly host?: string; + readonly webAsset?: { gzipped: Uint8Array }; +} + +export interface VisDeps { + readonly getHomeDir: () => string; + readonly startVisServer: (opts: StartVisServerArgs) => Promise; + readonly openUrl: (url: string) => Promise; + readonly waitForShutdown: () => Promise; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface VisOptions { + readonly open: boolean; + readonly port?: number; + readonly host?: string; + readonly sessionId?: string; +} + +export async function handleVis(deps: VisDeps, opts: VisOptions): Promise { + const homeDir = deps.getHomeDir(); + + // Lazily load the embedded single-file SPA so normal `kimi` startup never + // pays for it. The module is generated at build time (prebuild). When running + // from source without a build — e.g. tests — the generated value module is + // absent and the dynamic import throws; in that case the server falls back to + // its own static `public/` directory. + let webAsset: { gzipped: Uint8Array } | undefined; + try { + const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset'); + if (VIS_WEB_GZIP_B64.length > 0) { + webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) }; + } + } catch { + // Embedded asset not generated in this context — fall back to filesystem. + } + + let server: StartedVisServer; + try { + server = await deps.startVisServer({ + homeDir, + port: opts.port ?? 0, + ...(opts.host === undefined ? {} : { host: opts.host }), + ...(webAsset === undefined ? {} : { webAsset }), + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + deps.stderr.write(`Failed to start kimi vis: ${msg}\n`); + return deps.exit(1); + } + + const target = + opts.sessionId === undefined + ? server.url + : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; + + deps.stdout.write(`kimi vis is running at ${server.url}\n`); + deps.stdout.write('Press Ctrl-C to stop.\n'); + + if (opts.open) { + try { + await deps.openUrl(target); + } catch { + deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`); + } + } + + await deps.waitForShutdown(); + await server.close(); +} + +export function registerVisCommand(parent: Command, overrides?: Partial): void { + parent + .command('vis') + .description('Launch the session visualizer in your browser.') + .option('--port ', 'Port to bind. Default: auto-pick a free port.') + .option('--host ', 'Host to bind. Default: 127.0.0.1.') + .option('--no-open', 'Do not open the browser automatically.') + .argument('[sessionId]', 'Open directly to this session.') + .action( + async ( + sessionId: string | undefined, + options: { port?: string; host?: string; open?: boolean }, + ) => { + const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); + await handleVis(createDefaultVisDeps(overrides), { + open: options.open !== false, + ...(port === undefined || Number.isNaN(port) ? {} : { port }), + ...(options.host === undefined ? {} : { host: options.host }), + ...(sessionId === undefined ? {} : { sessionId }), + }); + }, + ); +} + +function createDefaultVisDeps(overrides: Partial = {}): VisDeps { + return { + getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), + startVisServer: + overrides.startVisServer ?? + (async (opts) => { + // Dynamic import keeps the vis server (and Hono) out of the hot path. + const { startVisServer } = await import('@moonshot-ai/vis-server/start'); + return startVisServer(opts); + }), + // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep. + openUrl: + overrides.openUrl ?? + (async (url: string) => { + openUrl(url); + }), + waitForShutdown: overrides.waitForShutdown ?? waitForSigint, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function waitForSigint(): Promise { + return new Promise((resolve) => { + const onSig = (): void => { + process.off('SIGINT', onSig); + resolve(); + }; + process.on('SIGINT', onSig); + }); +} diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index b0f85fd01..b228c913e 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -1,8 +1,23 @@ import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; -import { initializeTelemetry } from '@moonshot-ai/kimi-telemetry'; -import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { + KimiAuthFacade, + loadRuntimeConfigSafe, + resolveConfigPath, + resolveKimiHome, + type KimiConfig, + type KimiHarness, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import { + initializeTelemetry, + setTelemetryContext, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; -import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; +import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; + +import { createKimiCodeHostIdentity } from './version'; export interface CliTelemetryBootstrap { readonly homeDir: string; @@ -17,6 +32,7 @@ export interface InitializeCliTelemetryOptions { readonly version: string; readonly uiMode: string; readonly model?: string; + readonly sessionId?: string; } export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { @@ -39,6 +55,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): version: options.version, uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, + sessionId: options.sessionId, getAccessToken: async () => (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); @@ -46,3 +63,67 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): options.harness.track('first_launch'); } } + +export interface InitializeServerTelemetryOptions { + readonly version: string; +} + +/** + * Bootstrap telemetry for the `kimi web` / `kimi server run` host. + * + * Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to + * honor the `telemetry` toggle and pick up the default model, attaches the + * sink with `ui_mode = "web"`, and returns a {@link TelemetryClient} the + * caller hands to `startServer` via `coreProcessOptions.telemetry`. That wires + * the same real client into `KimiCore`, so agent-core events emitted inside the + * server process (`mcp_connected`, `session_load_failed`, plan-mode / cron + * events, …) actually leave the process carrying the enriched context + * (`app_name` / `version` / `ui_mode` / `model` / platform fields). + * + * The returned client wraps the `@moonshot-ai/kimi-telemetry` module + * functions, so the module-level `track` / `withTelemetryContext` (used to + * fire the startup event) share the same underlying client + sink. + */ +export function initializeServerTelemetry( + options: InitializeServerTelemetryOptions, +): TelemetryClient { + const bootstrap = createCliTelemetryBootstrap(); + const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir }); + const config = readServerTelemetryConfig(configPath); + const auth = new KimiAuthFacade({ + homeDir: bootstrap.homeDir, + configPath, + identity: createKimiCodeHostIdentity(options.version), + }); + + initializeTelemetry({ + homeDir: bootstrap.homeDir, + deviceId: bootstrap.deviceId, + enabled: config.telemetry !== false, + appName: CLI_USER_AGENT_PRODUCT, + version: options.version, + uiMode: WEB_UI_MODE, + model: config.defaultModel, + getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + + return { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; +} + +function readServerTelemetryConfig( + configPath: string, +): Pick { + try { + const { config, fileError } = loadRuntimeConfigSafe(configPath); + // A broken config fails the server on its own inside KimiCore; for + // telemetry just degrade to "enabled, no model" so we never block startup. + if (fileError !== undefined) return {}; + return config; + } catch { + return {}; + } +} diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts index 83132bf36..63885e2bb 100644 --- a/apps/kimi-code/src/cli/update/cache.ts +++ b/apps/kimi-code/src/cli/update/cache.ts @@ -3,13 +3,20 @@ import { z } from 'zod'; import { getUpdateStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { UpdateManifestSchema } from './cdn'; import { emptyUpdateCache, type UpdateCache } from './types'; -const UpdateCacheSchema: z.ZodType = z +// Stays `.strict()` (we own this file), but a malformed manifest is treated +// as no manifest so one bad optional field does not discard the whole cache. +const UpdateCacheSchema = z .object({ source: z.literal('cdn'), checkedAt: z.string().min(1).nullable(), latest: z.string().min(1).nullable(), + manifest: z.preprocess((value) => { + const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value); + return parsed.success ? parsed.data : null; + }, z.union([UpdateManifestSchema, z.null()])), }) .strict(); diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 5664f021f..4990568c9 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,6 +1,49 @@ import { valid } from 'semver'; +import { z } from 'zod'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; + +import type { UpdateManifest } from './types'; + +const CDN_FETCH_TIMEOUT_MS = 3_000; + +const RolloutBatchSchema = z.object({ + percent: z.number().int().min(0).max(100), + delaySeconds: z.number().int().min(0), +}); + +/** + * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown + * fields are ignored so future manifest additions never break shipped + * clients (the plain-text `/latest` taught us that hard-failing on + * unexpected content bricks the update path forever). + */ +export const UpdateManifestSchema = z.object({ + version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), + publishedAt: z + .string() + .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), + rollout: z.array(RolloutBatchSchema).readonly().default([]), +}); + +export interface FetchLatestResult { + /** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */ + readonly latest: string; + /** Null when the JSON manifest was unavailable and we fell back to plain text. */ + readonly manifest: UpdateManifest | null; +} + +async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, CDN_FETCH_TIMEOUT_MS); + try { + return await fetchImpl(input, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} /** * Fetch the latest published Kimi Code version from the CDN. @@ -15,7 +58,7 @@ import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL); + const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -25,3 +68,30 @@ export async function fetchLatestVersionFromCdn( } return raw; } + +async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { + const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL); + if (!response.ok) { + throw new Error(`CDN /latest.json returned HTTP ${response.status}`); + } + return UpdateManifestSchema.parse(JSON.parse(await response.text())); +} + +/** + * Fetch the rollout manifest, falling back to the plain-text `/latest` when + * `latest.json` is unavailable or malformed. The fallback removes any + * deployment-order coupling between client releases and the CDN file, and a + * null manifest means "fully rolled out" — exactly the pre-rollout behavior. + * + * **Throws** only when both sources fail; callers must catch (see above). + */ +export async function fetchLatestFromCdn( + fetchImpl: typeof fetch = fetch, +): Promise { + const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); + if (manifest !== null) { + return { latest: manifest.version, manifest }; + } + const latest = await fetchLatestVersionFromCdn(fetchImpl); + return { latest, manifest: null }; +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 3d28b5609..5fda96d6a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -19,13 +19,22 @@ import { type InstallPromptOptions, } from './prompt'; import { refreshUpdateCache } from './refresh'; -import { selectUpdateTarget } from './select'; +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + resolveUpdateDeviceId, + rolloutBucket, + rolloutDelayForBucket, + type PassiveUpdateDecision, +} from './rollout'; import { detectInstallSource } from './source'; import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, type UpdateInstallState, + type UpdateManifest, type UpdatePreflightResult, type UpdateTarget, } from './types'; @@ -177,8 +186,59 @@ function refreshInBackground(): void { void refreshUpdateCache().catch(() => {}); } +/** Telemetry properties describing where this device sits in the rollout. */ +interface RolloutTelemetry { + readonly rollout_bucket: number; + readonly rollout_delay_seconds: number; + readonly rollout_from_manifest: boolean; + readonly rollout_bypassed: boolean; +} + +function rolloutTelemetryFor( + deviceId: string, + targetVersion: string, + manifest: UpdateManifest | null, + bypassRollout: boolean, +): RolloutTelemetry { + const bucket = rolloutBucket(deviceId, targetVersion); + return { + rollout_bucket: bucket, + rollout_delay_seconds: + manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket), + rollout_from_manifest: manifest !== null, + rollout_bypassed: bypassRollout, + }; +} + +type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh'; + +/** Record which case a passive version check hit in `updates/rollout.log`. */ +function logRolloutDecision( + phase: RolloutCheckPhase, + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + decision: PassiveUpdateDecision, +): void { + void appendRolloutDecisionLog({ + ts: nowIso(), + phase, + reason: decision.reason, + current: currentVersion, + latest, + target: decision.target?.version ?? null, + manifestPresent: manifest !== null, + publishedAt: manifest?.publishedAt ?? null, + bucket: decision.bucket, + delaySeconds: decision.delaySeconds, + eligibleAt: decision.eligibleAt, + }); +} + function refreshAndMaybeInstallInBackground( currentVersion: string, + deviceId: string, + bypassRollout: boolean, isInteractive: boolean, installState: UpdateInstallState, platform: NodeJS.Platform, @@ -188,7 +248,16 @@ function refreshAndMaybeInstallInBackground( void (async () => { const refreshed = await refreshUpdateCache(); if (!isInteractive) return; - const target = selectUpdateTarget(currentVersion, refreshed.latest); + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + const target = decision.target; if (target === null) return; const source = await detectInstallSource().catch(() => 'unsupported' as const); await tryStartAutomaticBackgroundInstall( @@ -199,27 +268,54 @@ function refreshAndMaybeInstallInBackground( platform, track, logger, + rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout), ); })().catch(() => {}); } +interface UserVisibleUpdateTarget { + readonly target: UpdateTarget | null; + readonly manifest: UpdateManifest | null; +} + async function refreshUserVisibleUpdateTarget( currentVersion: string, + deviceId: string, + bypassRollout: boolean, fallbackTarget: UpdateTarget, -): Promise { + fallbackManifest: UpdateManifest | null, +): Promise { let timeout: ReturnType | undefined; + const fallback: UserVisibleUpdateTarget = { + target: fallbackTarget, + manifest: fallbackManifest, + }; try { const refresh = refreshUpdateCache() - .then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest)) - .catch(() => fallbackTarget); - const fallback = new Promise((resolve) => { + .then((refreshed) => { + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + return { + target: decision.target, + manifest: refreshed.manifest, + }; + }) + .catch(() => fallback); + const timeoutFallback = new Promise((resolve) => { timeout = setTimeout(() => { - resolve(fallbackTarget); + resolve(fallback); }, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS); }); - return await Promise.race([refresh, fallback]); + return await Promise.race([refresh, timeoutFallback]); } catch { - return fallbackTarget; + return fallback; } finally { if (timeout !== undefined) { clearTimeout(timeout); @@ -331,14 +427,14 @@ function trackUpdatePrompted( target: UpdateTarget, source: InstallSource, decision: UpdateDecision, + rolloutTelemetry: RolloutTelemetry, ): void { trackUpdateEvent(track, 'update_prompted', { - current: currentVersion, - latest: target.version, current_version: currentVersion, target_version: target.version, source, decision, + ...rolloutTelemetry, }); } @@ -392,7 +488,14 @@ export async function installUpdate( ): Promise { const { cmd, args } = spawnForSource(source, version, platform); await new Promise((resolve, reject) => { - const child = spawn(cmd, [...args], { stdio: 'inherit' }); + // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the + // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without + // a shell, so run through the shell on win32. The version is a validated + // semver and the package name is a constant, so args are shell-safe. + const child = spawn(cmd, [...args], { + stdio: 'inherit', + shell: platform === 'win32' ? true : undefined, + }); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) { @@ -413,6 +516,7 @@ async function startBackgroundInstall( platform: NodeJS.Platform, track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, ): Promise { const lock = await tryAcquireUpdateInstallLock({ version: target.version }); if (lock === null) return; @@ -439,6 +543,7 @@ async function startBackgroundInstall( current_version: currentVersion, target_version: target.version, source, + ...rolloutTelemetry, }); logUpdateInfo(logger, 'background update install started', { currentVersion, @@ -498,7 +603,15 @@ async function startBackgroundInstall( }); }; - const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' }); + const child = spawn(cmd, [...args], { + detached: true, + stdio: 'ignore', + shell: platform === 'win32' ? true : undefined, + // On Windows a detached child gets its own console window; with shell:true + // that window would flash during a passive background update. Hide it so + // the silent updater stays silent. + windowsHide: platform === 'win32' ? true : undefined, + }); child.once('error', () => { finish(false); }); child.once('exit', (code) => { finish(code === 0); }); child.unref(); @@ -515,6 +628,7 @@ async function tryStartAutomaticBackgroundInstall( platform: NodeJS.Platform, track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, ): Promise { const sourceCanAutoInstall = canAutoInstall(source, platform); const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; @@ -531,6 +645,7 @@ async function tryStartAutomaticBackgroundInstall( platform, track, logger, + rolloutTelemetry, ).catch(() => {}); } return true; @@ -562,6 +677,8 @@ export async function runUpdatePreflight( try { const isInteractive = options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); + const deviceId = resolveUpdateDeviceId(); + const bypassRollout = isRolloutBypassedByExperimentalEnv(); let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); if (isInteractive) { installState = await showPendingBackgroundInstallNotice( @@ -574,11 +691,22 @@ export async function runUpdatePreflight( } const cache = await readUpdateCache().catch(() => null); - const latest = cache?.latest ?? null; - const target = selectUpdateTarget(currentVersion, latest); + const cachedManifest = cache?.manifest ?? null; + const cachedDecision = decidePassiveUpdateTarget( + currentVersion, + cache?.latest ?? null, + cachedManifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision); + const target = cachedDecision.target; if (target === null) { refreshAndMaybeInstallInBackground( currentVersion, + deviceId, + bypassRollout, isInteractive, installState, platform, @@ -608,14 +736,28 @@ export async function runUpdatePreflight( platform, options.track, logger, + rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), ) ) { refreshInBackground(); return 'continue'; } - const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target); + const userVisibleUpdate = await refreshUserVisibleUpdateTarget( + currentVersion, + deviceId, + bypassRollout, + target, + cachedManifest, + ); + const userVisibleTarget = userVisibleUpdate.target; if (userVisibleTarget === null) return 'continue'; + const userVisibleRollout = rolloutTelemetryFor( + deviceId, + userVisibleTarget.version, + userVisibleUpdate.manifest, + bypassRollout, + ); if ( await tryStartAutomaticBackgroundInstall( installState, @@ -625,13 +767,14 @@ export async function runUpdatePreflight( platform, options.track, logger, + userVisibleRollout, ) ) { return 'continue'; } const installCommand = installCommandFor(source, userVisibleTarget.version, platform); - trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision); + trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout); if (decision === 'manual-command') { stdout.write(renderManualUpdateMessage( diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts index 20bcdf0de..938a4a0fa 100644 --- a/apps/kimi-code/src/cli/update/refresh.ts +++ b/apps/kimi-code/src/cli/update/refresh.ts @@ -1,13 +1,14 @@ import { writeUpdateCache } from './cache'; -import { fetchLatestVersionFromCdn } from './cdn'; +import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; import { type UpdateCache } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the latest semver. **Throws** on any failure — callers - * (including the default background invocation in preflight) must catch. - * Errors intentionally skip `writeCache` so a transient CDN blip does not - * overwrite a previously known `latest` with `null`. */ - readonly fetchLatest: () => Promise; + /** Resolves with the latest version + rollout manifest. **Throws** on any + * failure — callers (including the default background invocation in + * preflight) must catch. Errors intentionally skip `writeCache` so a + * transient CDN blip does not overwrite a previously known `latest` with + * `null`. */ + readonly fetchLatest: () => Promise; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -16,16 +17,17 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { const resolved: RefreshUpdateCacheDeps = { - fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()), + fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const latest = await resolved.fetchLatest(); + const { latest, manifest } = await resolved.fetchLatest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), latest, + manifest, }; await resolved.writeCache(cache); return cache; diff --git a/apps/kimi-code/src/cli/update/rollout.ts b/apps/kimi-code/src/cli/update/rollout.ts new file mode 100644 index 000000000..a4ca50256 --- /dev/null +++ b/apps/kimi-code/src/cli/update/rollout.ts @@ -0,0 +1,210 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk'; + +import { getUpdateRolloutLogFile } from '#/utils/paths'; + +import { selectUpdateTarget } from './select'; +import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; + +/** + * Hard ceiling for any rollout delay. Combined with the uncovered-bucket + * fallback below, it guarantees every device sees a release no later than + * `publishedAt + 24h`, no matter what the published plan says. + */ +export const MAX_ROLLOUT_DELAY_SECONDS = 86_400; + +/** + * Deterministic 0-99 bucket for a device. The version is mixed into the hash + * so each release reshuffles which devices land in the early batches. + */ +export function rolloutBucket(deviceId: string, version: string): number { + const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest(); + return digest.readUInt32BE(0) % 100; +} + +/** + * Delay assigned to a bucket by the published plan. Batches claim bucket + * ranges in array order; buckets left uncovered (percents summing under 100) + * fall into the slowest cohort, and oversized delays are clamped to 24h. + */ +export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number { + let cumulative = 0; + for (const batch of rollout) { + cumulative += batch.percent; + if (bucket < cumulative) { + return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS); + } + } + if (rollout.length === 0) return 0; + return MAX_ROLLOUT_DELAY_SECONDS; +} + +export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number { + return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version)); +} + +export function isRolloutEligible( + manifest: UpdateManifest, + deviceId: string, + now: Date, +): boolean { + const publishedAt = Date.parse(manifest.publishedAt); + // Schema validation rejects unparseable timestamps before they get here; + // fail open defensively so a defect can never block updates indefinitely. + if (!Number.isFinite(publishedAt)) return true; + const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000; + return now.getTime() >= publishedAt + delayMs; +} + +/** Which case a passive update check hit; written to the rollout log. */ +export type PassiveUpdateReason = + /** Nothing known yet (no cache / CDN never reached). */ + | 'no-latest' + /** Known version is not an upgrade over the running one. */ + | 'not-newer' + /** Plain-text fallback or legacy cache: visible immediately, no gating. */ + | 'no-manifest' + /** Gated: this device's batch delay has not elapsed yet. */ + | 'held' + /** Gated and the batch delay has elapsed: update is visible. */ + | 'eligible' + /** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ + | 'experimental'; + +export interface PassiveUpdateDecision { + readonly target: UpdateTarget | null; + readonly reason: PassiveUpdateReason; + readonly bucket: number | null; + readonly delaySeconds: number | null; + readonly eligibleAt: string | null; +} + +/** + * Update decision for the passive surfaces (background install, startup + * prompt, manual-command notice). Devices whose batch is not yet eligible see + * no update at all. A null manifest (plain-text fallback or legacy cache) + * keeps the pre-rollout behavior: the latest version is visible immediately. + * + * `kimi upgrade` must NOT go through this gate — it selects directly from the + * raw latest version. + */ +export function decidePassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, + bypassRollout = false, +): PassiveUpdateDecision { + if (bypassRollout) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + if (manifest === null) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + const target = selectUpdateTarget(currentVersion, manifest.version); + if (target === null) { + return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null }; + } + + const bucket = rolloutBucket(deviceId, manifest.version); + const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket); + const publishedAt = Date.parse(manifest.publishedAt); + const eligibleAt = Number.isFinite(publishedAt) + ? new Date(publishedAt + delaySeconds * 1000).toISOString() + : null; + const eligible = isRolloutEligible(manifest, deviceId, now); + return { + target: eligible ? target : null, + reason: eligible ? 'eligible' : 'held', + bucket, + delaySeconds, + eligibleAt, + }; +} + +export function selectPassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, +): UpdateTarget | null { + return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target; +} + +const ROLLOUT_LOG_MAX_BYTES = 256 * 1024; + +/** + * Append one JSON line describing a passive update decision to + * `/updates/rollout.log`. Best-effort diagnostics: any I/O failure + * is swallowed — logging must never affect update prompting. The file is + * reset once it grows past a small cap so it cannot grow unbounded. + */ +export async function appendRolloutDecisionLog( + entry: Record, + filePath: string = getUpdateRolloutLogFile(), +): Promise { + try { + await mkdir(dirname(filePath), { recursive: true }); + const line = `${JSON.stringify(entry)}\n`; + const size = await stat(filePath).then((s) => s.size, () => 0); + if (size > ROLLOUT_LOG_MAX_BYTES) { + await writeFile(filePath, line, 'utf-8'); + return; + } + await appendFile(filePath, line, 'utf-8'); + } catch { + // Diagnostic logging must never affect the update flow. + } +} + +/** + * Stable per-installation id used for bucketing when telemetry has already + * minted one. Missing ids stay ephemeral here so update preflight never + * creates the telemetry device_id before telemetry can emit first_launch. + */ +export function resolveUpdateDeviceId(): string { + return readKimiDeviceId(resolveKimiHome()) ?? randomUUID(); +} + +/** + * The experimental master switch opts a device out of staged rollouts: the + * newest version is always visible to the passive update surfaces, exactly as + * if every release were fully rolled out. Read directly from the env (same + * truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs + * before the harness exists, so the core flag registry is not consulted. + * `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in. + */ +export function isRolloutBypassedByExperimentalEnv( + env: Readonly> = process.env, +): boolean { + const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(value); +} diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts index ff4c93c1d..b03af767d 100644 --- a/apps/kimi-code/src/cli/update/types.ts +++ b/apps/kimi-code/src/cli/update/types.ts @@ -16,10 +16,28 @@ export interface UpdateTarget { readonly version: string; } +/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */ +export interface RolloutBatch { + readonly percent: number; + readonly delaySeconds: number; +} + +/** + * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array + * order; an empty array means the release is fully rolled out immediately. + */ +export interface UpdateManifest { + readonly version: string; + readonly publishedAt: string; + readonly rollout: readonly RolloutBatch[]; +} + export interface UpdateCache { readonly source: 'cdn'; readonly checkedAt: string | null; readonly latest: string | null; + /** Null when the manifest came from the plain-text fallback or a legacy cache file. */ + readonly manifest: UpdateManifest | null; } export interface UpdateInstallActive { @@ -54,6 +72,7 @@ export function emptyUpdateCache(): UpdateCache { source: 'cdn', checkedAt: null, latest: null, + manifest: null, }; } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 535ec138e..e38ea6a68 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -7,10 +7,33 @@ export const PROCESS_NAME = 'kimi-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; export const CLI_UI_MODE = 'shell'; +// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product +// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. +export const WEB_UI_MODE = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; +// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a +// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive +// firewall) must not keep a completed run alive indefinitely — once this elapses +// we stop waiting on cleanup and let the run return. +export const PROMPT_CLEANUP_TIMEOUT_MS = 8000; + +// Grace after a headless run has fully completed (turn done, cleanup attempted) +// before force-exiting. `kimi -p` otherwise relies on the event loop draining to +// exit; a stray ref'd handle (socket/timer/child) left over from the run would +// wedge it. The guard timer is unref'd, so a healthy run still exits naturally +// well before this fires. +export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000; + +// Max time to wait for buffered stdout/stderr to flush before arming the +// force-exit fallback. A slow/piped consumer's still-draining stdio is a +// legitimate ref'd handle — flushing first prevents the fallback from +// truncating completed output. Bounded so a permanently-stuck consumer can't +// re-introduce the hang. +export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000; + // Published npm package name; this can differ from the executable command. export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; @@ -18,12 +41,16 @@ export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME'; export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; export const KIMI_CODE_LOG_DIR_NAME = 'logs'; +export const KIMI_CODE_CACHE_DIR_NAME = 'cache'; export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; export const KIMI_CODE_BIN_DIR_NAME = 'bin'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; +export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; +export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; +export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; // Managed Kimi auth provider key shared with OAuth/SDK config. export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; @@ -45,6 +72,11 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +// Rollout manifest consumed by update checks; the plain-text `/latest` above +// stays unchanged forever — already-shipped clients hard-fail on non-semver +// bodies, and the CDN install scripts read it for fresh installs. +export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; diff --git a/apps/kimi-code/src/feedback/archive.ts b/apps/kimi-code/src/feedback/archive.ts new file mode 100644 index 000000000..439f68d1b --- /dev/null +++ b/apps/kimi-code/src/feedback/archive.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getCacheDir } from '../utils/paths'; + +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. + +/** + * A file produced for a feedback attachment upload. Both the session log + * archive and the codebase archive share this shape; the generic uploader + * consumes it without caring how the file was produced. + */ +export interface FeedbackArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; +} + +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +} + +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); +} diff --git a/apps/kimi-code/src/feedback/codebase/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts new file mode 100644 index 000000000..1df5976e9 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/filter.ts @@ -0,0 +1,92 @@ +export const DEFAULT_MAX_FILES = 50000; +export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; +// Upper bound for the compressed codebase archive, aligned with the backend's +// per-upload limit. The scanner uses cumulative raw file size as a conservative +// estimate so the resulting zip stays within this bound. +export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024; + +const IGNORED_DIR_NAMES: ReadonlySet = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.turbo', + '.cache', + '.parcel-cache', + 'coverage', + '.nyc_output', + 'target', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.venv', + 'venv', + 'env', + '.idea', +]); + +const SENSITIVE_DIR_NAMES: ReadonlySet = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.kube', + '.docker', +]); + +const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'credentials.json', + 'service-account.json', + 'serviceAccount.json', + '.netrc', + '.htpasswd', + '.pypirc', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', +]); + +const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ + '.pem', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', +]; + +const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet = new Set(['.example', '.sample', '.template']); + +export function isIgnoredDirName(name: string): boolean { + return IGNORED_DIR_NAMES.has(name); +} + +export function isSensitivePath(relativePath: string): boolean { + const segments = relativePath.split('/'); + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true; + } + + const base = segments.at(-1); + if (base === undefined || base.length === 0) return false; + if (SENSITIVE_FILE_NAMES.has(base)) return true; + if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + + if (base.startsWith('.env.')) { + const suffix = base.slice('.env'.length); + return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix); + } + + return false; +} diff --git a/apps/kimi-code/src/feedback/codebase/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts new file mode 100644 index 000000000..7ef51870d --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/index.ts @@ -0,0 +1,3 @@ +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts new file mode 100644 index 000000000..cd4bf2c66 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/packager.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Pack the scanned codebase into a zip, with files placed at the zip root. + */ +export async function packageCodebase( + scan: FeedbackCodebaseScanResult, + archivePath: string, +): Promise { + const entries: PackageEntry[] = scan.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +async function packageEntries( + entries: readonly PackageEntry[], + archivePath: string, +): Promise { + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback archive.'); + } + await mkdir(dirname(archivePath), { recursive: true }); + + const zip = new ZipFile(); + const hash = createHash('sha256'); + const output = createWriteStream(archivePath); + + try { + const done = new Promise((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} diff --git a/apps/kimi-code/src/feedback/codebase/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts new file mode 100644 index 000000000..6df420217 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 000000000..a611d93a3 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts new file mode 100644 index 000000000..c372e4d11 --- /dev/null +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise, +): Promise { + const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename); + try { + const archive = await produce(archivePath); + await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function archiveFromExportedSession(zipPath: string): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/kimi-code/src/feedback/upload.ts b/apps/kimi-code/src/feedback/upload.ts new file mode 100644 index 000000000..629fc6a6f --- /dev/null +++ b/apps/kimi-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadArchiveOptions, +): Promise { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: part.method, + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/kimi-code/src/generated/vis-web-asset.d.ts b/apps/kimi-code/src/generated/vis-web-asset.d.ts new file mode 100644 index 000000000..9b87ddcf5 --- /dev/null +++ b/apps/kimi-code/src/generated/vis-web-asset.d.ts @@ -0,0 +1 @@ +export declare const VIS_WEB_GZIP_B64: string; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -23,6 +23,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { createProgram } from './cli/commands'; +import { finalizeHeadlessRun } from './cli/headless-exit'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; @@ -38,7 +39,22 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -export async function handleMainCommand(opts: CLIOptions, version: string): Promise { +/** + * Outcome of a CLI command run, reported back to the process entrypoint. + * + * `handleMainCommand` is a reusable, unit-tested handler — it must not terminate + * the process itself. It reports here whether a headless (`kimi -p`) run + * completed so the entrypoint (the only place that owns the process) can arm the + * force-exit fallback. + */ +export interface MainCommandOutcome { + readonly headlessCompleted: boolean; +} + +export async function handleMainCommand( + opts: CLIOptions, + version: string, +): Promise { let validated: ReturnType; try { validated = validateOptions(opts); @@ -60,10 +76,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom if (validated.uiMode === 'print') { await runPrompt(validated.options, version); - return; + return { headlessCompleted: true }; } await runShell(validated.options, version); + return { headlessCompleted: false }; } /** `kimi migrate`: launch the migration screen only, then exit. */ @@ -139,17 +156,42 @@ export function main(): void { const program = createProgram( version, (opts) => { - void handleMainCommand(opts, version).catch(async (error: unknown) => { - const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; - await logStartupFailure(operation, error); - process.stderr.write( - formatStartupError(error, { - operation, - }), - ); - process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); - process.exit(1); - }); + void handleMainCommand(opts, version) + .then(async (outcome) => { + // Only the process entrypoint disposes of the process. Print mode + // relies on the event loop draining to exit; flush any buffered output + // and then arm an unref'd fallback so a stray ref'd handle left over + // from the run can't wedge a completed `kimi -p` until an external + // timeout. A healthy run drains and exits before the fallback fires. + if (outcome.headlessCompleted) { + await finalizeHeadlessRun( + process, + [process.stdout, process.stderr], + () => Number(process.exitCode) || 0, + ); + } + }) + .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); }, () => { void handleMigrateCommand(version).catch(async (error: unknown) => { diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index 6d1f89e18..d4c4fee7e 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -11,10 +11,11 @@ * This file implements the ask, progress, and result phases. `beginMigration` * drives the real runMigration flow (injectable for tests). */ -import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { resolveMigrationScope, runMigration as realRunMigration, @@ -46,7 +47,7 @@ export interface MigrationScreenOptions { readonly plan: MigrationPlan; readonly sourceHome: string; readonly targetHome: string; - readonly colors: ColorPalette; + readonly colors?: ColorPalette; /** Called once the screen is finished; the host then restores the editor. */ readonly onComplete: (result: MigrationScreenResult) => void; /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ @@ -278,7 +279,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderResult(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; if (this.migrationFailed) { lines.push(chalk.hex(colors.error).bold(' Migration failed')); @@ -428,7 +429,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderProgress(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), @@ -458,7 +459,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } private renderAsk(width: number): string[] { - const { colors } = this.opts; + const colors = this.opts.colors ?? currentTheme.palette; const step = this.currentStep(); const lines: string[] = [ chalk.hex(colors.primary)('─'.repeat(width)), diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts index bbef5d4cb..bc8a1a67b 100644 --- a/apps/kimi-code/src/native/module-hook.ts +++ b/apps/kimi-code/src/native/module-hook.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { join } from 'node:path'; -import { loadNativePackage } from './native-require'; +import { getNativePackageRoot } from './native-assets'; type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; @@ -10,7 +12,16 @@ interface ModuleWithLoad { const nodeRequire = createRequire(import.meta.url); let installed = false; -let loadingNativePackage = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary +// those .node files live in the native-asset cache, so redirect any absolute +// require of a pi-tui native helper to the cached copy. +// +// Path shape: native//prebuilds//.node — note the +// two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/; export function installNativeModuleHook(): void { if (installed) return; @@ -26,13 +37,18 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } } } return originalLoad.call(this, request, parent, isMain); diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts index 576c8079e..d66547695 100644 --- a/apps/kimi-code/src/native/native-assets.ts +++ b/apps/kimi-code/src/native/native-assets.ts @@ -12,6 +12,7 @@ import { import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { join as joinPosix } from 'pathe'; import { KIMI_BUILD_INFO } from '#/cli/build-info'; import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; @@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR'); if (cacheDirEnv !== null) return cacheDirEnv; - if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code'); + if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code'); if (platform === 'win32') { const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); return localAppData !== null @@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { : pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache'); } - return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code'); + return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code'); } export function getNativeAssetCacheRoot( diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 56d39253f..c77f1419d 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,6 +1,38 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -const smokePackages = ['@mariozechner/clipboard', 'koffi']; +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; + +// Verify pi-tui's native helper can actually be loaded through the module hook. +// pi-tui computes native helper paths from process.execPath and require()s them; +// those paths do not exist next to the SEA binary, so this only succeeds when +// installNativeModuleHook() redirects the require into the native-asset cache. +function smokePiTuiNativeLoad(): void { + const platform = process.platform; + const arch = process.arch; + let rel: string | undefined; + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node'); + } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node'); + } + if (rel === undefined) return; // Linux: no native helper, nothing to load. + + const req = createRequire(import.meta.url); + const bogusPath = join(dirname(process.execPath), rel); + const helper = req(bogusPath) as { + isModifierPressed?: unknown; + enableVirtualTerminalInput?: unknown; + }; + const ok = + typeof helper.isModifierPressed === 'function' || + typeof helper.enableVirtualTerminalInput === 'function'; + if (!ok) { + throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`); + } +} export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; @@ -16,6 +48,7 @@ export function runNativeAssetSmokeIfRequested(): boolean { throw new Error(`Native package is not available: ${packageName}`); } } + smokePiTuiNativeLoad(); process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); process.exit(0); } catch (error) { diff --git a/apps/kimi-code/src/native/web-assets.ts b/apps/kimi-code/src/native/web-assets.ts new file mode 100644 index 000000000..6fb0845e5 --- /dev/null +++ b/apps/kimi-code/src/native/web-assets.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildWebManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface WebAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface WebAssetManifest { + readonly version: typeof WEB_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly root: 'dist-web'; + readonly files: readonly WebAssetFile[]; +} + +export type WebAssetSource = NativeAssetSource; + +export interface WebAssetOptions { + readonly source?: WebAssetSource | null; + readonly manifest?: WebAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawWebAssetManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts new file mode 100644 index 000000000..087b02934 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -0,0 +1,331 @@ +import { createHash } from 'node:crypto'; + +import { gte, valid } from 'semver'; + +import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; +import type { BannerDisplay, BannerState } from '#/tui/types'; + +import type { BannerDisplayState } from './state'; + +interface TipsBannerFallbackItem { + banner_id?: string | null; + enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; +} + +interface TipsBannerJson { + banner_id?: string | null; + banner_enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; + banner_fallback_enabled?: boolean; + banner_fallback_list?: unknown[]; +} + +interface BannerHashInput { + tag: string | null; + mainText: string; + subText: string | null; + startTime: string | null; + endTime: string | null; + display: BannerDisplay; + ttlHours?: number; +} + +interface BannerCandidateInput { + id: unknown; + tag: unknown; + mainText: string; + subText: unknown; + display: BannerDisplay; + ttlHours?: number; + startTime?: unknown; + endTime?: unknown; +} + +export interface SelectDisplayableBannerArgs { + json: unknown; + clientVersion: string; + now: Date; + random: () => number; + state: BannerDisplayState; +} + +interface BannerProviderLoadOptions { + state?: BannerDisplayState; + now?: Date; + random?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; +export const DEFAULT_COOLDOWN_TTL_HOURS = 24; + +function normalizeTag(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeUtcDate(value: string): string { + if (value.endsWith('Z')) return value; + if (/[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.length === 0) return null; + const normalized = normalizeUtcDate(value); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean { + if (start !== null && now < start) return false; + if (end !== null && now > end) return false; + return true; +} + +function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { + if (minVersion === undefined || minVersion === null) return true; + if (typeof minVersion !== 'string' || minVersion.length === 0) return true; + const min = valid(minVersion); + const current = valid(clientVersion); + if (min === null || current === null) return false; + return gte(current, min); +} + +function parseBannerDisplay(value: unknown): BannerDisplay { + if (value === 'once') return 'once'; + if (value === 'cooldown') return 'cooldown'; + return 'always'; +} + +function parseBannerDisplayTtlHours(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +function normalizeBannerId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function hashBannerIdentity(input: BannerHashInput): string { + const raw = JSON.stringify([ + input.tag ?? '', + input.mainText, + input.subText ?? '', + input.startTime ?? '', + input.endTime ?? '', + input.display, + input.ttlHours ?? '', + ]); + return createHash('sha256').update(raw).digest('hex').slice(0, 32); +} + +function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string { + return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input); +} + +function toBannerState(input: BannerCandidateInput): BannerState { + const tag = normalizeTag(input.tag); + const subText = normalizeText(input.subText); + const display = input.display; + const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined; + const startTime = normalizeText(input.startTime); + const endTime = normalizeText(input.endTime); + const key = getBannerKey(input.id, { + tag, + mainText: input.mainText, + subText, + startTime, + endTime, + display, + ttlHours, + }); + + return { + key, + tag, + mainText: input.mainText, + subText, + display, + ttlHours, + }; +} + +function pickActiveBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState | null { + if (json.banner_enabled !== true) return null; + if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + const start = parseDate(json.banner_start_time); + const end = parseDate(json.banner_end_time); + if (!isWithinWindow(start, end, now)) return null; + const mainText = normalizeText(json.banner_maintext); + if (mainText === null) return null; + const display = parseBannerDisplay(json.banner_display); + return toBannerState({ + id: json.banner_id, + tag: json.banner_title, + mainText, + subText: json.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined, + startTime: json.banner_start_time, + endTime: json.banner_end_time, + }); +} + +function pickFallbackCandidates( + json: TipsBannerJson, + clientVersion: string, +): BannerState[] { + if (json.banner_fallback_enabled !== true) return []; + const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; + const candidates: BannerState[] = []; + for (const raw of list) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as TipsBannerFallbackItem; + if (item.enabled !== true) continue; + if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + const mainText = normalizeText(item.banner_maintext); + if (mainText === null) continue; + const display = parseBannerDisplay(item.banner_display); + candidates.push( + toBannerState({ + id: item.banner_id, + tag: item.banner_title, + mainText, + subText: item.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined, + }), + ); + } + return candidates; +} + +function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null { + if (candidates.length === 0) return null; + const index = Math.floor(random() * candidates.length); + return candidates[index]!; +} + +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + random: () => number, +): BannerState | null { + return pickRandomCandidate(pickFallbackCandidates(json, clientVersion), random); +} + +function parseShownAt(value: string | undefined): Date | null { + if (value === undefined) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getCooldownTtlHours(banner: BannerState): number { + return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0 + ? banner.ttlHours + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +export function shouldDisplayBanner( + banner: BannerState, + state: BannerDisplayState, + now: Date, +): boolean { + if (banner.display === 'always') return true; + const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt); + if (lastShownAt === null) return true; + if (banner.display === 'once') return false; + return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS; +} + +export function selectBannerState( + json: unknown, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + return ( + pickActiveBanner(typed, clientVersion, now) ?? + pickFallbackBanner(typed, clientVersion, random) + ); +} + +export function selectDisplayableBanner({ + json, + clientVersion, + now, + random, + state, +}: SelectDisplayableBannerArgs): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + const active = pickActiveBanner(typed, clientVersion, now); + if (active !== null && shouldDisplayBanner(active, state, now)) return active; + const candidates = pickFallbackCandidates(typed, clientVersion).filter((candidate) => + shouldDisplayBanner(candidate, state, now), + ); + return pickRandomCandidate(candidates, random); +} + +export class BannerProvider { + constructor( + private readonly clientVersion: string, + private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, + ) {} + + async load( + fetchImpl: typeof fetch = fetch, + options: BannerProviderLoadOptions = {}, + ): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + const response = await fetchImpl(this.url, { signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return null; + const json = await response.json(); + const now = options.now ?? new Date(); + const random = options.random ?? Math.random; + return options.state === undefined + ? selectBannerState(json, this.clientVersion, now, random) + : selectDisplayableBanner({ + json, + clientVersion: this.clientVersion, + now, + random, + state: options.state, + }); + } catch { + return null; + } + } +} diff --git a/apps/kimi-code/src/tui/banner/state.ts b/apps/kimi-code/src/tui/banner/state.ts new file mode 100644 index 000000000..77877d82a --- /dev/null +++ b/apps/kimi-code/src/tui/banner/state.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { getBannerStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +export type BannerDisplayRecord = { + lastShownAt: string; +}; + +export type BannerDisplayState = { + version: 1; + shown: Record; +}; + +const BannerDisplayRecordSchema = z + .object({ + lastShownAt: z.string().min(1), + }) + .strict(); + +const BannerDisplayStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const shown = (value as { shown?: unknown }).shown; + if (typeof shown !== 'object' || shown === null) { + return { ...(value as Record), shown: {} }; + } + + const normalizedShown: Record = {}; + for (const [key, record] of Object.entries(shown)) { + if (key.length === 0 || typeof record !== 'object' || record === null) continue; + const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt; + if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue; + normalizedShown[key] = { lastShownAt }; + } + + return { ...(value as Record), shown: normalizedShown }; + }, + z + .object({ + version: z.literal(1), + shown: z.record(z.string().min(1), BannerDisplayRecordSchema), + }) + .strict(), +); + +export function emptyBannerDisplayState(): BannerDisplayState { + return { + version: 1, + shown: {}, + }; +} + +export async function readBannerDisplayState( + filePath: string = getBannerStateFile(), +): Promise { + try { + return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState()); + } catch { + return emptyBannerDisplayState(); + } +} + +export async function writeBannerDisplayState( + value: BannerDisplayState, + filePath: string = getBannerStateFile(), +): Promise { + await writeJsonFile(filePath, BannerDisplayStateSchema, value); +} diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 000000000..90636cea5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -0,0 +1,91 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; + +type AddDirChoice = 'session' | 'remember' | 'cancel'; + +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { + const input = args.trim(); + const session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + const additionalDirs = session?.summary?.additionalDirs ?? []; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); + return; + } + + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: 'session', + label: 'Yes, for this session', + }, + { + value: 'remember', + label: 'Yes, and remember this directory', + }, + { + value: 'cancel', + label: 'No', + }, + ], + onSelect: (value) => { + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); + }, + onCancel: () => { + host.restoreEditor(); + host.showStatus(`Did not add ${input} as a working directory.`); + }, + }), + ); +} + +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( + host: SlashCommandHost, + sessionId: string, + path: string, + choice: AddDirChoice, +): Promise { + host.restoreEditor(); + + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); + return; + } + + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 8064c089b..773638485 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { } host.track('login', { provider: DEFAULT_OAUTH_PROVIDER_NAME, + method: 'oauth', already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { @@ -158,7 +159,11 @@ async function handleOpenPlatformLogin( platform, models, selectedModel: selection.model, - thinking: selection.thinking, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, apiKey, }); @@ -166,7 +171,7 @@ async function handleOpenPlatformLogin( providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); await host.authFlow.refreshConfigAfterLogin(); diff --git a/apps/kimi-code/src/tui/commands/complete-args.ts b/apps/kimi-code/src/tui/commands/complete-args.ts index 75f76271d..e377c9bb0 100644 --- a/apps/kimi-code/src/tui/commands/complete-args.ts +++ b/apps/kimi-code/src/tui/commands/complete-args.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; /** * A completable token (subcommand or flag) for a slash command's argument diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 3e652217e..9d9597419 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,25 +1,30 @@ -import type { - ExperimentalFeatureState, - FlagId, - PermissionMode, - Session, +import { + effectiveModelAlias, + type ExperimentalFeatureState, + type ModelAlias, + type PermissionMode, + type Session, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; -import { saveTuiConfig } from '../config'; -import type { Theme } from '../theme'; +import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; +import type { ThemeName } from '#/tui/theme'; +import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; -import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; @@ -28,6 +33,18 @@ import type { SlashCommandHost } from './dispatch'; // Plan / Config commands // --------------------------------------------------------------------------- +const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; + +function currentTuiConfig(host: SlashCommandHost): TuiConfig { + return { + theme: host.state.appState.theme, + editorCommand: host.state.appState.editorCommand, + disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, + }; +} + export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -90,7 +107,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.'); + host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); return; } @@ -113,7 +130,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.'); + host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); } } @@ -134,7 +151,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.'); + host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); return; } @@ -157,7 +174,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.'); + host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); } } @@ -186,15 +203,19 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): showThemePicker(host); return; } - if (!isTheme(theme)) { - host.showError(`Unknown theme: ${theme}`); - return; + if (!isBuiltInTheme(theme)) { + const custom = await loadCustomThemeMerged(theme); + if (custom === null) { + host.showError(`Unknown theme: ${theme}`); + return; + } } await applyThemeChoice(host, theme); } -export function handleModelCommand(host: SlashCommandHost, args: string): void { +export async function handleModelCommand(host: SlashCommandHost, args: string): Promise { const alias = args.trim(); + await refreshModelsForPicker(host); if (alias.length === 0) { showModelPicker(host); return; @@ -206,6 +227,56 @@ export function handleModelCommand(host: SlashCommandHost, args: string): void { showModelPicker(host, alias); } +export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); + return; + } + const effective = effectiveModelAlias(model); + const segments = segmentsFor(effective); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, effective, segments); + return; + } + if (!segments.includes(arg)) { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + await performModelSwitch(host, alias, arg, true); +} + +function showEffortPicker( + host: SlashCommandHost, + model: ModelAlias, + segments: readonly string[], +): void { + const liveEffort = host.state.appState.thinkingEffort; + const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off'); + const alias = host.state.appState.model; + host.mountEditorReplacement( + new EffortSelectorComponent({ + efforts: segments, + currentValue, + onSelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, true); + }, + onSessionOnlySelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + // --------------------------------------------------------------------------- // Pickers & config apply // --------------------------------------------------------------------------- @@ -215,7 +286,6 @@ function showEditorPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new EditorSelectorComponent({ currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyEditorChoice(host, value); @@ -227,6 +297,37 @@ function showEditorPicker(host: SlashCommandHost): void { ); } +async function refreshModelsForPicker(host: SlashCommandHost): Promise { + try { + const result = await withTimeout( + host.authFlow.refreshOAuthProviderModels(), + MODEL_PICKER_REFRESH_TIMEOUT_MS, + ); + if (result === undefined) return; + for (const f of result.failed) { + host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); + } + } catch (error) { + host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning'); + } +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { const previous = host.state.appState.editorCommand ?? ''; if (value === previous && value.length > 0) { @@ -237,15 +338,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise const editorCommand = value.length > 0 ? value : null; try { await saveTuiConfig({ - theme: host.state.appState.theme, + ...currentTuiConfig(host), editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, }); } catch (error) { host.showStatus( `Failed to save editor: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } @@ -272,11 +371,14 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = models: host.state.appState.availableModels, currentValue: host.state.appState.model, selectedValue, - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); }, onCancel: () => { host.restoreEditor(); @@ -285,27 +387,34 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } -async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persist: boolean, +): Promise { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; } - const level = thinking ? 'on' : 'off'; const prevModel = host.state.appState.model; - const prevThinking = host.state.appState.thinking; - const runtimeChanged = alias !== prevModel || thinking !== prevThinking; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); const session = host.session; try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, thinking); + await host.authFlow.activateModelAfterLogin(alias, effort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); } - if (thinking !== prevThinking) { - await session.setThinking(level); + if (effort !== prevEffort) { + await session.setThinking(effort); } } } catch (error) { @@ -314,41 +423,65 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin return; } - host.setAppState({ model: alias, thinking }); + host.setAppState({ model: alias, thinkingEffort: effort }); if (session === undefined && runtimeChanged) { if (alias !== prevModel) { host.track('model_switch', { model: alias }); } - if (thinking !== prevThinking) { - host.track('thinking_toggle', { enabled: thinking }); + if (effort !== prevEffort) { + host.track('thinking_toggle', { + enabled: effort !== 'off', + effort, + from: prevEffort, + }); } } let persisted = false; - try { - persisted = await persistModelSelection(host, alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; + if (persist) { + try { + persisted = await persistModelSelection(host, alias, effort); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); + return; + } } - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; - host.showStatus(status, host.state.theme.colors.success); + let status: string; + if (modelChanged) { + status = persist + ? `Switched to ${displayName} with thinking ${effort}.` + : `Switched to ${displayName} with thinking ${effort} for this session only.`; + } else if (effortChanged) { + status = persist + ? `Thinking set to ${effort}.` + : `Thinking set to ${effort} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${displayName} with thinking ${effort} as default.`; + } else { + status = `Already using ${displayName} with thinking ${effort}.`; + } + host.showStatus(status, 'success'); } -async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise { const config = await host.harness.getConfig({ reload: true }); - if (config.defaultModel === alias && config.defaultThinking === thinking) { + const patch = thinkingEffortToConfig(effort); + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + config.thinking?.effort === patch.effort + ) { return false; } await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: patch, }); return true; } @@ -357,7 +490,6 @@ function showThemePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new ThemeSelectorComponent({ currentValue: host.state.appState.theme, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyThemeChoice(host, value); @@ -369,30 +501,41 @@ function showThemePicker(host: SlashCommandHost): void { ); } -async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { +async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise { if (theme === host.state.appState.theme) { if (theme === 'auto') host.refreshTerminalThemeTracking(); host.showStatus(`Theme unchanged: "${theme}".`); return; } + // Validate custom themes up front so a missing / malformed file reports an + // error instead of silently persisting a name that resolves to the dark + // fallback. + if (!isBuiltInTheme(theme)) { + const palette = await loadCustomThemeMerged(theme); + if (palette === null) { + host.showStatus(`Theme "${theme}" could not be loaded.`, 'error'); + return; + } + } + try { await saveTuiConfig({ + ...currentTuiConfig(host), theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, }); } catch (error) { host.showStatus( `Failed to save theme: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } - const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme; - host.applyTheme(theme, resolved); + const resolved = theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(theme, resolved); host.refreshTerminalThemeTracking(); host.track('theme_switch', { theme }); const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; @@ -403,7 +546,6 @@ export function showPermissionPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new PermissionSelectorComponent({ currentValue: host.state.appState.permissionMode, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyPermissionChoice(host, value); @@ -419,7 +561,6 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new UpdatePreferenceSelectorComponent({ currentValue: host.state.appState.upgrade.autoInstall, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); void applyUpdatePreferenceChoice(host, value); @@ -449,12 +590,12 @@ export async function applyExperimentalFeatureChanges( if (changes.length === 0) { host.showStatus( 'No experimental feature changes to apply.', - host.state.theme.colors.textMuted, + 'textMuted', ); return; } - const experimental: Partial> = {}; + const experimental: Record = {}; for (const change of changes) { experimental[change.id] = change.enabled; } @@ -472,7 +613,7 @@ export async function applyExperimentalFeatureChanges( 'Experimental features updated. Session reloaded.', ); } else { - host.showStatus('Experimental features updated.', host.state.theme.colors.success); + host.showStatus('Experimental features updated.', 'success'); } host.track('experimental_features_apply', { changed: changes.length }); } catch (error) { @@ -487,7 +628,6 @@ function mountExperimentsPanel( host.mountEditorReplacement( new ExperimentsSelectorComponent({ features, - colors: host.state.theme.colors, onApply: (changes) => { void applyExperimentalFeatureChanges(host, changes); }, @@ -504,7 +644,6 @@ type UpdatePreferenceHost = { SlashCommandHost['state']['appState'], 'theme' | 'editorCommand' | 'notifications' | 'upgrade' >; - readonly theme: Pick; }; setAppState(patch: Pick): void; showStatus(msg: string, color?: string): void; @@ -523,15 +662,13 @@ export async function applyUpdatePreferenceChoice( const upgrade = { autoInstall }; try { await saveTuiConfig({ - theme: host.state.appState.theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, + ...currentTuiConfig(host as unknown as SlashCommandHost), upgrade, }); } catch (error) { host.showStatus( `Failed to save automatic update setting: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, + 'error', ); return; } @@ -562,7 +699,6 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod export function showSettingsSelector(host: SlashCommandHost): void { host.mountEditorReplacement( new SettingsSelectorComponent({ - colors: host.state.theme.colors, onSelect: (value) => { handleSettingsSelection(host, value); }, diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b3e4593ac..7508bc06a 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,33 +1,31 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Theme } from '../theme'; -import type { ResolvedTheme } from '../theme/colors'; -import { - LLM_NOT_SET_MESSAGE, -} from '../constant/kimi-tui'; -import { formatErrorMessage } from '../utils/event-payload'; -import { parseSlashInput } from './parse'; -import { - resolveSlashCommandInput, - slashBusyMessage, -} from './resolve'; -import type { BuiltinSlashCommandName } from './registry'; +import type { ColorToken, ThemeName } from '#/tui/theme'; + +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; import type { AuthFlowController } from '../controllers/auth-flow'; import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; -import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; +import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import type { ResolvedTheme } from '../theme/colors'; import type { TUIState } from '../tui-state'; - +import type { + AppState, + LoginProgressSpinnerHandle, + QueuedMessage, + TranscriptEntry, +} from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; -import { tryHandleDanceCommand } from '../easter-eggs/dance'; import { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -38,11 +36,14 @@ import { showSettingsSelector, } from './config'; import { handleGoalCommand } from './goal'; -import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; +import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; +import { handleProviderCommand } from './provider'; +import type { BuiltinSlashCommandName } from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { handleSwarmCommand } from './swarm'; +import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -50,21 +51,22 @@ import { handleInitCommand, handleTitleCommand, } from './session'; +import { handleSwarmCommand } from './swarm'; import { handleUndoCommand } from './undo'; +import { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working // --------------------------------------------------------------------------- -export { - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -75,12 +77,7 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; @@ -92,6 +89,7 @@ export { handleTitleCommand, } from './session'; export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Host interface @@ -107,8 +105,9 @@ export interface SlashCommandHost { setAppState(patch: Partial): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; track(event: string, props?: Record): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; @@ -130,17 +129,25 @@ export interface SlashCommandHost { showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme - applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise; refreshTerminalThemeTracking(): void; // Dispatch stop(exitCode?: number): Promise; + setExitOpenUrl(url: string): void; showHelpPanel(): void; createNewSession(): Promise; showSessionPicker(): Promise; sendNormalUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void; readonly skillCommandMap: Map; + readonly pluginCommandMap: Map; // Controller refs readonly streamingUI: StreamingUIController; @@ -166,6 +173,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, }); @@ -197,6 +205,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 @@ -251,6 +273,9 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'add-dir': + await handleAddDirCommand(host, args); + return; case 'experiments': await showExperimentsPanel(host); return; @@ -267,7 +292,10 @@ async function handleBuiltInSlashCommand( await handleThemeCommand(host, args); return; case 'model': - handleModelCommand(host, args); + await handleModelCommand(host, args); + return; + case 'effort': + await handleEffortCommand(host, args); return; case 'provider': await handleProviderCommand(host); @@ -332,6 +360,9 @@ async function handleBuiltInSlashCommand( case 'undo': await handleUndoCommand(host, args); return; + case 'web': + await handleWebCommand(host); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index b2b83e81e..de790b906 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -206,7 +206,7 @@ async function queueNextGoal( host.track('goal_queue_append'); if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); host.state.transcriptContainer.addChild( - new UpcomingGoalAddedMessageComponent(host.state.theme.colors), + new UpcomingGoalAddedMessageComponent(), ); host.state.ui.requestRender(); } @@ -228,7 +228,6 @@ async function showGoalQueueManager( new GoalQueueManagerComponent({ goals: snapshot.goals, selectedGoalId, - colors: host.state.theme.colors, onAction: async (action) => { try { return await handleGoalQueueManagerAction(host, action); @@ -291,7 +290,6 @@ async function showGoalQueueEditDialog( host.mountEditorReplacement( new GoalQueueEditDialogComponent({ goal, - colors: host.state.theme.colors, onDone: (result) => { void handleGoalQueueEditResult(host, result).catch((error: unknown) => { host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`); @@ -354,7 +352,6 @@ function showGoalStartPermissionPrompt( }; host.mountEditorReplacement( new GoalStartPermissionPromptComponent({ - colors: host.state.theme.colors, mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', onSelect: (choice) => { if (choice === 'cancel') { @@ -375,10 +372,19 @@ async function startGoalWithPermission( choice: GoalStartPermissionChoice, options: GoalStartOptions, ): Promise { - if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) { + const previousMode = host.state.appState.permissionMode; + const switched = + choice !== previousMode && (choice === 'auto' || choice === 'yolo'); + if (switched) { if (!(await setPermissionForGoal(host, choice))) return; } - await startGoal(host, parsed, options); + const started = await startGoal(host, parsed, options); + // The permission switch only exists to run this goal. If creation fails + // (e.g. a goal already exists and `replace` was not given), restore the + // previous mode so the session is not left more permissive than before. + if (!started && switched) { + await setPermissionForGoal(host, previousMode); + } } async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise { @@ -415,8 +421,7 @@ async function startGoal( if (options.beforeSend !== undefined && !(await options.beforeSend())) { return false; } - host.track('goal_create', { replace: parsed.replace }); - host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors)); + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); host.state.ui.requestRender(); if (options.sendInput !== undefined) { options.sendInput(parsed.objective); @@ -488,7 +493,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise { return; } host.state.transcriptContainer.addChild( - new GoalStatusMessageComponent(goal, host.state.theme.colors), + new GoalStatusMessageComponent(goal), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 769571a62..784ef7e61 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,13 +3,11 @@ 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'; -export { - handleLoginCommand, - handleLogoutCommand, -} from './auth'; +export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; export { handleCompactCommand, @@ -24,22 +22,14 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { - handleFeedbackCommand, - showMcpServers, - showStatusReport, - showUsage, -} from './info'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand, parseGoalCommand } from './goal'; export { goalArgumentCompletions } from './registry'; -export { - handleForkCommand, - handleInitCommand, - handleTitleCommand, -} from './session'; +export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; +export { handleWebCommand } from './web'; export { promptApiKey, promptCatalogProviderSelection, diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index f49b8d22f..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -9,17 +9,21 @@ import { FEEDBACK_ISSUE_URL, FEEDBACK_STATUS_CANCELLED, FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, 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 +43,60 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); + // Stage 2: ask whether to attach diagnostics (logs / codebase). + const level = await promptFeedbackAttachment(host); + if (level === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); return; } - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + const version = withFeedbackVersionPrefix(host.state.appState.version); + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + // Guarantee the spinner's underlying setInterval is always cleared, even when + // submitFeedback or submitFeedbackWithAttachments throws — otherwise the + // interval (and its per-frame requestRender) leaks for the rest of the session. + let stopped = false; + const stopSpinner = (opts: { ok: boolean; label: string }): void => { + if (stopped) return; + stopped = true; + spinner.stop(opts); + }; + try { + const res = await host.harness.auth.submitFeedback({ + content: input.value, + sessionId: host.state.appState.sessionId, + version, + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind !== 'ok') { + stopSpinner({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + + stopSpinner({ 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); + } + } catch (error) { + stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR }); + throw error; + } } // --------------------------------------------------------------------------- @@ -87,8 +121,7 @@ interface ManagedUsageResult { export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); const managedUsage = await loadManagedUsageReport(host); - const lines = buildUsageReportLines({ - colors: host.state.theme.colors, + const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, @@ -96,8 +129,8 @@ export async function showUsage(host: SlashCommandHost): Promise { maxContextTokens: host.state.appState.maxContextTokens, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + }; + const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -108,14 +141,13 @@ export async function showStatusReport(host: SlashCommandHost): Promise { loadManagedUsageReport(host), ]); const appState = host.state.appState; - const lines = buildStatusReportLines({ - colors: host.state.theme.colors, + const reportArgs = { version: appState.version, model: appState.model, workDir: appState.workDir, sessionId: appState.sessionId, sessionTitle: appState.sessionTitle, - thinking: appState.thinking, + thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, contextUsage: appState.contextUsage, @@ -126,8 +158,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise { statusError: runtimeStatus.error, managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + }; + const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -141,12 +173,12 @@ export async function showMcpServers(host: SlashCommandHost): Promise { return; } - const lines = buildMcpStatusReportLines({ - colors: host.state.theme.colors, - servers, - }); const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildMcpStatusReportLines({ servers }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -181,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise; +} + +export function pluginCommandName(pluginId: string, name: string): string { + return `${pluginId}:${name}`; +} + +export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands { + const commandMap = new Map(); + const commands = defs.map((def) => { + const commandName = pluginCommandName(def.pluginId, def.name); + commandMap.set(commandName, def.body); + return { + name: commandName, + aliases: [], + description: def.description, + } satisfies KimiSlashCommand; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 0420e07a8..b69f0724a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -4,14 +4,15 @@ import { isAbsolute, join, resolve } from 'node:path'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, - type PluginMarketplaceSelection, type PluginRemoveConfirmResult, - type PluginsOverviewSelection, + type PluginsPanelSelection, + type PluginsPanelTabId, } from '../components/dialogs/plugins-selector'; import { buildPluginsInfoLines, @@ -19,8 +20,9 @@ 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 { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -29,6 +31,8 @@ interface ShowPluginsPickerOptions { readonly id: string; readonly text: string; }; + readonly initialTab?: PluginsPanelTabId; + readonly marketplaceSource?: string; } interface PluginMcpServerHint { @@ -62,6 +66,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -73,7 +81,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { - await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined); + const marketplaceSource = rest.join(' ').trim() || undefined; + await showPluginsPicker(host, { + // Custom marketplaces often omit `tier`, so their entries land on the + // Third-party tab (entry.tier !== 'official'). Open there when a custom + // source is supplied; otherwise the default catalog's official entries + // make Official the right landing tab. + initialTab: marketplaceSource === undefined ? 'official' : 'third-party', + marketplaceSource, + }); return; } if (sub === 'info') { @@ -95,7 +111,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } await session.setPluginMcpServerEnabled(id, server, action === 'enable'); host.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, ); return; } @@ -118,8 +134,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showStatus(`Remove cancelled: ${id}.`); return; } - await session.removePlugin(id); - host.showStatus(`Removed ${id} (plugin files left in place).`); + await removePlugin(host, id); return; } if (sub === 'reload') { @@ -149,55 +164,58 @@ async function showPluginsPicker( return; } - host.mountEditorReplacement( - new PluginsOverviewSelectorComponent({ - plugins, - selectedId: options?.selectedId, - pluginHint: options?.pluginHint, - colors: host.state.theme.colors, - onSelect: (selection) => { - // Each branch of the handler either mounts the next view or restores - // the editor itself, so do not pre-restore here — that would flash the - // editor for in-place actions like toggling a plugin. - void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); + const panel = new PluginsPanelComponent({ + installed: plugins, + installedIds: new Set(plugins.map((plugin) => plugin.id)), + initialTab: options?.initialTab, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + onSelect: (selection) => { + // Each branch of the handler either mounts the next view or restores the + // editor itself, so do not pre-restore here — that would flash the editor + // for in-place actions like toggling a plugin. + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + // 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); + // 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); + } } -async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise { +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source?: string, +): Promise { try { - const [marketplace, installed] = await Promise.all([ - loadPluginMarketplace({ workDir: host.state.appState.workDir, source }), - host.requireSession().listPlugins(), - ]); - host.mountEditorReplacement( - new PluginMarketplaceSelectorComponent({ - entries: marketplace.plugins, - installedIds: new Set(installed.map((plugin) => plugin.id)), - source: marketplace.source, - colors: host.state.theme.colors, - onSelect: (selection) => { - // Every marketplace action re-mounts a picker, so let the handler do - // the mounting — pre-restoring the editor here would flash. - void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - void showPluginsPicker(host); - }, - }), - ); + const marketplace = await loadPluginMarketplace({ + workDir: host.state.appState.workDir, + source, + }); + panel.setMarketplace(marketplace.plugins, marketplace.source); } catch (error) { - host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); + panel.setMarketplaceError(formatErrorMessage(error)); } + host.state.ui.requestRender(); } async function showPluginMcpPicker( @@ -218,7 +236,6 @@ async function showPluginMcpPicker( info, selectedServer: options?.selectedServer, serverHint: options?.serverHint, - colors: host.state.theme.colors, onSelect: (selection) => { // Every MCP action re-mounts a picker, so let the handler do the // mounting — pre-restoring the editor here would flash on toggle. @@ -247,7 +264,6 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< new PluginRemoveConfirmComponent({ id, displayName, - colors: host.state.theme.colors, onDone: (result: PluginRemoveConfirmResult) => { host.restoreEditor(); resolveConfirmed(result.kind === 'confirm'); @@ -257,6 +273,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise { + // Kimi-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace...`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + async function applyPluginEnabled( host: SlashCommandHost, id: string, @@ -276,54 +353,71 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` : ''; if (showStatus) { - host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; return `${pluginInlineChangeHint()}${inlineMcpHint}`; } -async function handlePluginsOverviewSelection( +async function handlePluginsPanelSelection( host: SlashCommandHost, - selection: PluginsOverviewSelection, + panel: PluginsPanelComponent, + selection: PluginsPanelSelection, ): Promise { - const session = host.requireSession(); switch (selection.kind) { - case 'marketplace': - await showPluginMarketplacePicker(host); - return; - case 'reload': - await reloadPlugins(host); - await showPluginsPicker(host); - return; - case 'show-list': - host.restoreEditor(); - await renderPluginsList(host); - return; case 'toggle': { const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false); await showPluginsPicker(host, { + initialTab: 'installed', selectedId: selection.id, pluginHint: { id: selection.id, text: hint }, }); return; } - case 'mcp': - await showPluginMcpPicker(host, selection.id); - return; case 'remove': if (!(await confirmRemovePlugin(host, selection.id))) { host.showStatus(`Remove cancelled: ${selection.id}.`); - await showPluginsPicker(host, { selectedId: selection.id }); + await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id }); return; } - await session.removePlugin(selection.id); - host.showStatus(`Removed ${selection.id} (plugin files left in place).`); - await showPluginsPicker(host); + await removePlugin(host, selection.id); + await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'info': + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'details': host.restoreEditor(); await renderPluginInfo(host, selection.id); return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'install': + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); + return; + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); + return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; } } @@ -352,22 +446,10 @@ async function handlePluginMcpSelection( } } -async function handlePluginMarketplaceSelection( - host: SlashCommandHost, - selection: PluginMarketplaceSelection, -): Promise { - switch (selection.kind) { - case 'install': - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await installPluginFromSource(host, selection.entry.source, { - successNotice: 'marketplace', - }); - await showPluginsPicker(host, { selectedId: selection.entry.id }); - return; - case 'back': - await showPluginsPicker(host); - return; - } +async function removePlugin(host: SlashCommandHost, id: string): Promise { + await host.requireSession().removePlugin(id); + host.showStatus(`Removed ${id}.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -375,20 +457,23 @@ async function renderPluginsList( plugins?: readonly PluginSummary[], ): Promise { const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); - const lines = buildPluginsListLines({ - colors: host.state.theme.colors, - plugins: currentPlugins, - }); const title = ` Plugins (${currentPlugins.length}) `; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + const panel = new UsagePanelComponent( + () => buildPluginsListLines({ plugins: currentPlugins }), + 'primary', + title, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const info = await host.requireSession().getPluginInfo(id); - const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `); + const panel = new UsagePanelComponent( + () => buildPluginsInfoLines({ info }), + 'primary', + ` ${info.id} `, + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } @@ -396,25 +481,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const session = host.requireSession(); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), ); - showPluginInstallResult(host, beforeList, summary, options); + showPluginInstallResult(host, beforeList, summary); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], summary: PluginSummary, - options?: { - readonly successNotice?: 'marketplace'; - }, ): void { const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; @@ -423,15 +504,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 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 to apply plugin changes.`, - ); - } + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } function describeInstallAction( @@ -444,13 +518,19 @@ function describeInstallAction( return ` ${prev} → ${cur ?? '-'}`; }; if (previous === undefined) { - return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`; + return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`; } if (sourceIdentity(previous) !== sourceIdentity(next)) { const prevSourceLabel = formatPluginSourceLabel(previous); return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`; } - return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`; + return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`; +} + +// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding +// "from" would read as "from via ". Only prepend "from" otherwise. +function sourcePhrase(sourceLabel: string): string { + return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`; } function sourceIdentity(plugin: PluginSummary): string { @@ -481,5 +561,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'require run /new to apply'; + return 'run /reload or /new to apply'; } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index a6b7a8c80..5b0fd5533 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -4,6 +4,7 @@ import { type Catalog, type CatalogModel, type ModelAlias, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; import type { @@ -21,7 +22,6 @@ import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { return new Promise((resolve) => { const selector = new PlatformSelectorComponent({ - colors: host.state.theme.colors, onSelect: (platformId) => { host.restoreEditor(); resolve(platformId); @@ -45,7 +45,6 @@ export function promptLogoutProviderSelection( title: 'Select a provider to log out', options, currentValue, - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value); @@ -59,16 +58,58 @@ export function promptLogoutProviderSelection( }); } -export function promptFeedbackInput(host: SlashCommandHost): Promise { +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise { return new Promise((resolve) => { const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { host.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); - }, host.state.theme.colors); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); + }); host.mountEditorReplacement(dialog); }); } +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + export function promptApiKey( host: SlashCommandHost, platformName: string, @@ -82,7 +123,6 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); @@ -109,7 +149,6 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: const picker = new ChoicePickerComponent({ title: 'Select a provider', options, - colors: host.state.theme.colors, searchable: true, onSelect: (value) => { host.restoreEditor(); @@ -128,7 +167,7 @@ export async function promptModelSelectionForOpenPlatform( host: SlashCommandHost, models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, -): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { +): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = { @@ -149,7 +188,7 @@ export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], -): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { +): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); @@ -163,7 +202,7 @@ export async function promptModelSelectionForCatalog( export function runModelSelector( host: SlashCommandHost, modelDict: Record, -): Promise<{ alias: string; thinking: boolean } | undefined> { +): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> { return new Promise((resolve) => { const firstAlias = Object.keys(modelDict)[0] ?? ''; const caps = modelDict[firstAlias]?.capabilities ?? []; @@ -171,8 +210,7 @@ export function runModelSelector( const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, - currentThinking: initialThinking, - colors: host.state.theme.colors, + currentThinkingEffort: initialThinking ? 'on' : 'off', searchable: true, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index e8c4edfc1..eb416a54e 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -13,6 +13,7 @@ import { fetchCatalog, inferWireType, type Catalog, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; @@ -27,6 +28,7 @@ import { import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { promptApiKey, promptCatalogProviderSelection, @@ -49,12 +51,15 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt return { providers: host.state.appState.availableProviders, activeProviderId, - colors: host.state.theme.colors, onAdd: () => { - void handleProviderAdd(host); + void handleProviderAdd(host).catch((error: unknown) => { + host.showError(`Add provider failed: ${formatErrorMessage(error)}`); + }); }, onDeleteSource: (providerIds) => { - void handleProviderManagerDeleteSource(host, providerIds); + void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => { + host.showError(`Remove provider failed: ${formatErrorMessage(error)}`); + }); }, onClose: () => { host.restoreEditor(); @@ -132,7 +137,6 @@ function promptProviderAddSource( { value: 'known', label: 'Known third-party provider' }, { value: 'custom', label: 'Custom registry (api.json)' }, ], - colors: host.state.theme.colors, onSelect: (value) => { host.restoreEditor(); resolve(value === 'known' || value === 'custom' ? value : undefined); @@ -231,12 +235,13 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { models: mergedModels, currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); @@ -248,15 +253,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { async function setDefaultModel( host: SlashCommandHost, alias: string, - thinking: boolean, + effort: ThinkingEffort, ): Promise { await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: thinkingEffortToConfig(effort), }); await host.authFlow.refreshConfigAfterLogin(); host.track('model_switch', { model: alias }); - host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`); + host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise { @@ -272,8 +277,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { entries = await fetchCustomRegistry(source); - } catch (err) { - host.showError(`Failed to import registry: ${formatErrorMessage(err)}`); + } catch (error) { + host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; } @@ -290,8 +295,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: config.models, }); await host.authFlow.refreshConfigAfterLogin(); - } catch (err) { - host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`); + } catch (error) { + host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`); return false; } @@ -304,7 +309,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise count === 1 ? 'Imported 1 provider from registry.' : `Imported ${String(count)} providers from registry.`, - host.state.theme.colors.success, + 'success', ); // Offer the model selector so the user can pick a default, just like the @@ -320,12 +325,13 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: stateModels, currentValue: host.state.appState.model, selectedValue: firstNewAlias, - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, thinking); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); }, onCancel: () => { host.restoreEditor(); @@ -344,7 +350,6 @@ function promptCustomRegistryImport( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, - host.state.theme.colors, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..8e5a16386 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,4 +1,8 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; @@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'off', description: 'Turn swarm mode off' }, ]; +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); @@ -41,19 +49,102 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', aliases: ['yes'], - description: 'Toggle auto-approve mode', - priority: 100, + description: 'Toggle YOLO mode: AI auto-approves safe actions, asks for approval on risky ones.', + priority: 101, availability: 'always', }, { name: 'auto', aliases: [], - description: 'Toggle auto permission mode', - priority: 100, + description: 'Toggle Auto mode: run all actions automatically, including risky ones.', + priority: 99, availability: 'always', }, { @@ -82,6 +173,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Toggle swarm mode or run one task in swarm mode', priority: 100, + argumentHint: '[on|off] | ', completeArgs: swarmArgumentCompletions, availability: 'idle-only', }, @@ -92,6 +184,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', + priority: 95, + availability: 'always', + }, { name: 'provider', aliases: ['providers'], @@ -146,6 +245,15 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'add-dir', + aliases: [], + description: 'Add or list an additional workspace directory', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | ', + completeArgs: addDirArgumentCompletions, + }, { name: 'experiments', aliases: ['experimental'], @@ -172,16 +280,14 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Compact the conversation context', priority: 80, + argumentHint: '', }, { name: 'goal', aliases: [], description: 'Start or manage an autonomous goal', priority: 80, - // No argumentHint: the menu description stays as short as every other - // command's. The subcommands (status/pause/resume/cancel/replace) surface in - // the argument autocomplete list once the user types `/goal ` (see - // completeArgs), so they don't need to be spelled out inline. + argumentHint: '[status|pause|resume|cancel|replace|next] | ', completeArgs: goalArgumentCompletions, // status / pause / cancel are always available; creation, replacement, and // resume start (or restart) a turn and so are idle-only. @@ -209,6 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['rename'], description: 'Set or show session title', priority: 60, + argumentHint: '', availability: 'always', }, { @@ -277,6 +384,13 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'web', + aliases: [], + description: 'Open the current session in the Web UI and exit the terminal', + priority: 40, + availability: 'always', + }, { name: 'exit', aliases: ['quit', 'q'], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a93d7b6ec..6f28a16da 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,13 +1,14 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> { const tuiConfig = await loadTuiConfig(); - applyReloadedTuiConfig(host, tuiConfig); - host.showStatus('TUI config reloaded.', host.state.theme.colors.success); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise<void> { @@ -15,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.'); } @@ -23,28 +24,32 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void> setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); - applyReloadedTuiConfig(host, tuiConfig); + await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { host.showStatus( 'Runtime and TUI config reloaded; no active session.', - host.state.theme.colors.success, + 'success', ); } } -export function applyReloadedTuiConfig( +export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, -): void { - const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme; - host.applyTheme(config.theme, resolved); +): Promise<void> { + const resolved = config.theme === 'auto' + ? (currentTheme.palette === lightColors ? 'light' : 'dark') + : undefined; + await host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, + disablePasteBurst: config.disablePasteBurst, notifications: config.notifications, upgrade: config.upgrade, }); + host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index a47f11409..e67457a94 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -26,6 +26,12 @@ export type SlashCommandIntent = readonly skillName: string; readonly args: string; } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } | { readonly kind: 'message'; readonly input: string } | { readonly kind: 'blocked'; @@ -41,6 +47,7 @@ export type SlashCommandIntent = export interface ResolveSlashCommandInput { readonly input: string; readonly skillCommandMap: ReadonlyMap<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, diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 8ddfbda0e..2997a8b15 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -33,7 +33,10 @@ export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillS const commandMap = new Map<string, string>(); const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { - const commandName = skill.source === 'builtin' ? skill.name : `skill:${skill.name}`; + const commandName = + skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`; commandMap.set(commandName, skill.name); return { name: commandName, diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 65baa2fcc..540aa5860 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -57,7 +57,6 @@ function showSwarmStartPermissionPrompt( }; host.mountEditorReplacement( new SwarmStartPermissionPromptComponent({ - colors: host.state.theme.colors, onSelect: (choice) => { host.restoreEditor(); void onSelect(choice); @@ -72,7 +71,7 @@ async function startSwarmWithPermission( prompt: string, choice: SwarmStartPermissionChoice, ): Promise<void> { - if (choice === 'auto') { + if (choice === 'auto' || choice === 'yolo') { if (!(await setPermissionForSwarm(host, choice))) return; } await startSwarmTask(host, prompt); @@ -112,7 +111,9 @@ async function applySwarmMode( } if (enabled && host.state.appState.permissionMode === 'manual') { showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => { - if (choice === 'auto' && !(await setPermissionForSwarm(host, choice))) return; + if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForSwarm(host, choice))) { + return; + } if (!(await setSwarmMode(host, true, 'manual'))) return; renderSwarmModeMarker(host, 'active'); }); @@ -149,7 +150,7 @@ function swarmModeSubcommand(input: string): boolean | undefined { function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 6ee0a172f..1ec3c6835 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui'; +import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 286d5ec8a..bd427277d 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -1,6 +1,13 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; import { WelcomeComponent } from '../components/chrome/welcome'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { + UndoSelectorComponent, + type UndoChoice, +} from '../components/dialogs/undo-selector'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; @@ -8,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'; @@ -15,12 +23,24 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import type { TranscriptEntry } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata'; +import { nextTranscriptId } from '../utils/transcript-id'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Undo command // --------------------------------------------------------------------------- +interface UndoAvailability { + readonly maxCount: number; + readonly stoppedAtCompaction: boolean; +} + +type UndoSessionContext = Awaited< + ReturnType<NonNullable<SlashCommandHost['session']>['getContext']> +>; + +const UNDO_LIMIT_STATUS_TURN_ID = 'undo-limit-status'; + export async function handleUndoCommand( host: SlashCommandHost, args: string = '', @@ -30,7 +50,13 @@ export async function handleUndoCommand( return; } - const count = parseUndoCount(args); + const trimmed = args.trim(); + if (trimmed.length === 0) { + await showUndoSelector(host); + return; + } + + const count = parseUndoCount(trimmed); if (count === undefined) { host.showError('Usage: /undo [count], where count is a positive integer.'); return; @@ -42,19 +68,40 @@ export async function handleUndoCommand( return; } + const availability = await resolveUndoAvailability(host); + if (count > availability.maxCount) { + showUndoLimitStatus(host, formatUndoLimitMessage(count, availability)); + return; + } + + await undoByCount(host, count); +} + +async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return false; + } + const entries = host.state.transcriptEntries; const lastUserIndex = findUndoAnchorEntryIndex(entries, count); if (lastUserIndex === undefined) { - host.showError('Nothing to undo.'); - return; + showUndoLimitStatus(host, 'Nothing to undo.'); + return false; } try { await session.undoHistory(count); } catch (error) { + const limit = undoLimitFromError(error); + if (limit !== undefined) { + showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); + return false; + } const message = formatErrorMessage(error); host.showError(`Failed to undo: ${message}`); - return; + return false; } const children = host.state.transcriptContainer.children; @@ -74,6 +121,43 @@ export async function handleUndoCommand( } host.state.ui.requestRender(); + return true; +} + +async function showUndoSelector(host: SlashCommandHost): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const availability = await resolveUndoAvailability(host); + const choices = createUndoChoices( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + availability.maxCount, + ); + if (choices.length === 0) { + showUndoLimitStatus(host, formatNothingToUndoMessage(availability)); + return; + } + + host.mountEditorReplacement( + new UndoSelectorComponent({ + choices, + onSelect: (choice) => { + void undoByCount(host, choice.count).then((undone) => { + if (undone) { + host.restoreInputText(choice.input); + return; + } + host.restoreEditor(); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); } function parseUndoCount(args: string): number | undefined { @@ -84,10 +168,231 @@ function parseUndoCount(args: string): number | undefined { return Number.isSafeInteger(count) ? count : undefined; } +async function resolveUndoAvailability( + host: SlashCommandHost, +): Promise<UndoAvailability> { + const local = undoAvailabilityFromTranscript( + host.state.transcriptEntries, + host.state.transcriptContainer.children, + ); + const context = await getSessionContext(host.session); + if (context === undefined) return local; + + const activeContext = undoAvailabilityFromContext(context.history); + return { + maxCount: Math.min(local.maxCount, activeContext.maxCount), + stoppedAtCompaction: + local.stoppedAtCompaction || activeContext.stoppedAtCompaction, + }; +} + +async function getSessionContext( + session: SlashCommandHost['session'], +): Promise<UndoSessionContext | undefined> { + const getContext = ( + session as { getContext?: () => Promise<UndoSessionContext> } | undefined + )?.getContext; + if (session === undefined || getContext === undefined) return undefined; + try { + return await getContext.call(session); + } catch { + return undefined; + } +} + +function undoAvailabilityFromTranscript( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): UndoAvailability { + const { anchors, stoppedAtCompaction } = activeUndoAnchorEntries(entries, children); + return { + maxCount: anchors.length, + stoppedAtCompaction, + }; +} + +function undoAvailabilityFromContext( + history: readonly ContextMessage[], +): UndoAvailability { + let maxCount = 0; + let stoppedAtCompaction = false; + + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]; + if (message === undefined) continue; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isContextUndoAnchor(message)) maxCount++; + } + + return { maxCount, stoppedAtCompaction }; +} + +function isContextUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined || origin.kind === 'user') return true; + if (origin.kind === 'skill_activation') { + return origin.trigger === 'user-slash'; + } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } + return false; +} + +function createUndoChoices( + entries: readonly TranscriptEntry[], + children: readonly Component[], + maxCount: number, +): readonly UndoChoice[] { + if (maxCount <= 0) return []; + const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount); + return anchors.map((entry, index) => ({ + id: entry.id, + count: anchors.length - index, + input: formatUndoChoiceInput(entry), + label: formatUndoChoiceLabel(entry), + })); +} + +function activeUndoAnchorEntries( + entries: readonly TranscriptEntry[], + children: readonly Component[], +): { readonly anchors: readonly TranscriptEntry[]; readonly stoppedAtCompaction: boolean } { + const lastCompactionChildIndex = children.findLastIndex( + (child) => child instanceof CompactionComponent, + ); + if (lastCompactionChildIndex >= 0) { + return { + anchors: children + .slice(lastCompactionChildIndex + 1) + .map((child) => getTranscriptComponentEntry(child)) + .filter((entry): entry is TranscriptEntry => entry !== undefined) + .filter(isUndoAnchorEntry), + stoppedAtCompaction: true, + }; + } + + const lastCompactionEntryIndex = entries.findLastIndex( + (entry) => entry.compactionData !== undefined, + ); + const activeEntries = + lastCompactionEntryIndex >= 0 ? entries.slice(lastCompactionEntryIndex + 1) : entries; + return { + anchors: activeEntries.filter(isUndoAnchorEntry), + stoppedAtCompaction: lastCompactionEntryIndex >= 0, + }; +} + +function formatUndoChoiceLabel( + entry: TranscriptEntry, +): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return 'Skill: unknown'; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + 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; + if (content.length > 0) return content; + if (imageCount > 0) { + return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`; + } + return 'User message'; +} + +function formatUndoChoiceInput(entry: TranscriptEntry): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return ''; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + 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(); +} + +function formatUndoLimitMessage( + requestedCount: number, + availability: UndoAvailability, +): string { + const reason = availability.stoppedAtCompaction ? ' after the last compaction' : ''; + const requested = formatPromptCount(requestedCount); + const max = formatPromptCount(availability.maxCount); + return `Cannot undo ${requested}; only ${max} can be undone in the active context${reason}.`; +} + +function formatNothingToUndoMessage(availability: UndoAvailability): string { + if (availability.stoppedAtCompaction) { + return 'Nothing to undo after the last compaction.'; + } + return 'Nothing to undo.'; +} + +function formatPromptCount(count: number): string { + return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`; +} + +function showUndoLimitStatus(host: SlashCommandHost, message: string): void { + host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: UNDO_LIMIT_STATUS_TURN_ID, + renderMode: 'plain', + content: message, + }); +} + +function undoLimitFromError( + error: unknown, +): (UndoAvailability & { readonly requestedCount: number }) | undefined { + if (!isKimiError(error)) return undefined; + const details = error.details; + if (details?.['reason'] !== 'undo_limit') return undefined; + const requestedCount = details['requestedCount']; + const maxCount = details['undoableCount']; + const stoppedAtCompaction = details['stoppedAtCompaction']; + if ( + typeof requestedCount !== 'number' || + typeof maxCount !== 'number' || + typeof stoppedAtCompaction !== 'boolean' + ) { + return undefined; + } + return { requestedCount, maxCount, stoppedAtCompaction }; +} + function isUndoAnchorEntry(entry: TranscriptEntry): boolean { return ( entry.kind === 'user' || - (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') + (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') || + entry.kind === 'plugin_command' ); } @@ -113,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': @@ -153,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 ); } @@ -172,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 ); @@ -186,6 +494,6 @@ function renderWelcome(host: SlashCommandHost): void { return; } host.state.transcriptContainer.addChild( - new WelcomeComponent(host.state.appState, host.state.theme.colors), + new WelcomeComponent(host.state.appState), ); } diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts new file mode 100644 index 000000000..366860016 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -0,0 +1,92 @@ +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'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +const WEB_CONFIRM = 'confirm'; +const WEB_CANCEL = 'cancel'; + +/** + * `/web` — hand the current session off to the browser. + * + * Equivalent to `kimi server run` (ensures the background daemon is up) plus + * `kimi web` (opens the browser), but deep-linked to the active session and + * followed by shutting down this terminal UI. A confirmation step spells out + * the consequences and only proceeds when the user presses Enter on Continue. + */ +export async function handleWebCommand(host: SlashCommandHost): Promise<void> { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const sessionId = session.id; + + const confirmed = await new Promise<boolean>((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Open current session in the Web UI?', + hint: '↑↓ navigate · Enter select · Esc cancel', + options: [ + { + value: WEB_CONFIRM, + label: 'Continue', + description: + 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.', + }, + { + value: WEB_CANCEL, + label: 'Cancel', + description: 'Stay in the terminal UI.', + }, + ], + onSelect: (value) => { + resolve(value === WEB_CONFIRM); + }, + onCancel: () => { + resolve(false); + }, + }); + host.mountEditorReplacement(picker); + }); + host.restoreEditor(); + if (!confirmed) return; + + host.showStatus('Starting Kimi server and opening web UI…'); + let origin: string; + try { + ({ origin } = await ensureDaemon({})); + } catch (error) { + host.showError(`Failed to start server: ${formatErrorMessage(error)}`); + return; + } + + // 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. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `kimi web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; +} diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts new file mode 100644 index 000000000..58b6faa58 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -0,0 +1,76 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const PREFIX_STAR = '✦'; +const PADDING = ' '; + +export class BannerComponent implements Component { + constructor(private readonly state: BannerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const main = (s: string): string => currentTheme.boldFg('textStrong', s); + const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Render nothing but the trailing blank if the terminal cannot hold a + // single visible column. + if (width < 1) { + return ['']; + } + + const tagText = this.state.tag ?? ''; + // Do not add a colon/tag suffix here; the caller-provided tag includes its + // own punctuation/separator. + const tagLabel = tagText.length > 0 ? `${PREFIX_STAR} ${tagText}` : ''; + const tagStyled = tagLabel.length > 0 ? currentTheme.boldFg('primary', tagLabel) : ''; + const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; + const tagWidth = visibleWidth(tagDisplay); + const showTag = tagWidth > 0 && tagWidth < width; + // Body lines (continuations of the main text) indent to match the first + // line's main-text column, which starts right after the tag display. + const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // Descriptive subtext lines (the second line in the design) start at the + // column after the leading star + space, aligning with the tag text itself. + const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; + const bodyContentWidth = width - (showTag ? tagWidth : 0); + const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + + if (bodyContentWidth <= 0) { + return ['']; + } + + const mainSegments = this.state.mainText.split('\n'); + const subSegments = this.state.subText ? this.state.subText.split('\n') : []; + + const result: string[] = []; + for (let i = 0; i < mainSegments.length; i++) { + const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); + for (let j = 0; j < wrapped.length; j++) { + const boldLine = main(wrapped[j]!); + if (i === 0 && j === 0 && showTag) { + result.push(tagDisplay + boldLine); + } else { + result.push(bodyIndent + boldLine); + } + } + } + + for (const sub of subSegments) { + const available = descContentWidth <= 0 ? bodyContentWidth : descContentWidth; + const wrapped = wrapTextWithAnsi(sub, available); + for (const line of wrapped) { + result.push(descIndent + dim(line)); + } + } + + // Add a blank line below the banner so the following transcript content + // (e.g. the input prompt / status messages) is visually separated. + result.push(''); + + return result; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index de2704228..26ec211a0 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -6,18 +6,16 @@ * active palette so theme switches take effect on the next render. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface DeviceCodeBoxParams { readonly title: string; readonly url: string; readonly code: string; readonly hint?: string; - readonly colors: ColorPalette; } export class DeviceCodeBoxComponent implements Component { @@ -30,28 +28,33 @@ export class DeviceCodeBoxComponent implements Component { invalidate(): void {} render(width: number): string[] { - const { title, url, code, hint, colors } = this.params; - const border = (s: string): string => chalk.hex(colors.primary)(s); - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const { title, url, code, hint } = this.params; + const border = (s: string): string => currentTheme.fg('primary', s); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…'); + const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…'); const promptLine = truncateToWidth( - chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'), + currentTheme.fg('textDim', 'Visit the URL below in your browser to authorize:'), innerWidth, '…', ); - const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…'); + const urlLine = truncateToWidth(currentTheme.fg('primary', url), innerWidth, '…'); - const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: '); - const codeValue = chalk.bold.hex(colors.accent)(code); + const codeLabel = currentTheme.boldFg('textDim', 'Verification code: '); + const codeValue = currentTheme.boldFg('accent', code); const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; if (hint !== undefined && hint.length > 0) { contentLines.push(''); - contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…')); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); + } + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; } const lines: string[] = [ @@ -71,6 +74,6 @@ export class DeviceCodeBoxComponent implements Component { lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a81751f6c..b91193b53 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -6,11 +6,14 @@ * Line 2: context: XX.X% (tokens/max) */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { @@ -30,46 +33,9 @@ const GOAL_TIMER_INTERVAL_MS = 1_000; // important enough to take the whole slot on their own. A `priority` weight // makes a tip recur more often in the rotation (default 1). Width is always // the final arbiter (a pair that doesn't fit falls back to its first tip). -// -// This is deliberately code-level configuration: edit the interval and the -// TOOLBAR_TIPS array below to change what the footer advertises. const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; -export interface ToolbarTip { - readonly text: string; - /** - * Long/important tips render on their own. They never pair with a - * neighbour and never appear as the second half of someone else's pair. - */ - readonly solo?: boolean; - /** - * Rotation weight: a higher value makes the tip recur more often. Defaults - * to 1. Used to give newer/important features more airtime. - */ - readonly priority?: number; -} - -const TOOLBAR_TIPS: readonly ToolbarTip[] = [ - { text: 'shift+tab: plan mode' }, - { text: '/model: switch model' }, - { text: 'ctrl+s: steer mid-turn', priority: 2 }, - { text: '/compact: compact context', priority: 2 }, - { text: 'ctrl+o: expand tool output' }, - { text: '/tasks: background tasks' }, - { text: 'shift+enter: newline' }, - { text: '/init: generate AGENTS.md', priority: 2 }, - { text: '@: mention files' }, - { text: 'ctrl+c: cancel' }, - { text: '/theme: switch theme' }, - { text: '/auto: auto permission mode' }, - { text: '/yolo: toggle yolo' }, - { text: '/help: show commands' }, - { text: '/dance: rainbow mode, because why not' }, - { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, - { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, -]; - /** * Expand tips into a rotation sequence using smooth weighted round-robin * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while @@ -97,7 +63,7 @@ export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly Toolbar return seq; } -const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS); +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS); function currentTipIndex(): number { return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); @@ -167,7 +133,8 @@ function formatBadgeElapsed(ms: number): string { function modelDisplayName(state: AppState): string { const model = state.availableModels[state.model]; - return model?.displayName ?? model?.model ?? state.model; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? state.model; } function shortenCwd(path: string): string { @@ -206,7 +173,7 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { - const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); + const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; const pullRequest = chalk.hex(colors.primary)( @@ -217,7 +184,6 @@ export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): s export class FooterComponent implements Component { private state: AppState; - private colors: ColorPalette; private readonly onRefresh: () => void; private gitCache: GitStatusCache; private gitCacheWorkDir: string; @@ -235,9 +201,8 @@ export class FooterComponent implements Component { private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; - constructor(state: AppState, colors: ColorPalette, onRefresh: () => void = () => {}) { + constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; - this.colors = colors; this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); @@ -255,10 +220,6 @@ export class FooterComponent implements Component { this.state = state; } - setColors(colors: ColorPalette): void { - this.colors = colors; - } - /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -269,6 +230,10 @@ export class FooterComponent implements Component { this.transientHint = hint; } + getTransientHint(): string | null { + return this.transientHint; + } + /** * Sync both background-task badges with live counts. Each non-zero * count produces its own bracketed badge on line 1; zeros hide them @@ -282,7 +247,7 @@ export class FooterComponent implements Component { invalidate(): void {} render(width: number): string[] { - const colors = this.colors; + const colors = currentTheme.palette; const state = this.state; // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── @@ -299,11 +264,22 @@ export class FooterComponent implements Component { const model = modelDisplayName(state); if (model) { - const thinkingLabel = state.thinking ? ' thinking' : ''; + const effort = state.thinkingEffort; + const rawCurrentModel = state.availableModels[state.model]; + const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking: ${effort}` + : ' thinking' + : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { - renderedModelLabel = renderDanceFooterModel(modelLabel, colors); + renderedModelLabel = renderDanceFooterModel(modelLabel); } left.push(renderedModelLabel); } @@ -325,7 +301,7 @@ export class FooterComponent implements Component { } const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.status)(cwd)); + if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); const git = this.gitCache.getStatus(); if (git !== null) { @@ -407,6 +383,13 @@ export class FooterComponent implements Component { } } + dispose(): void { + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } + } + private goalWallClockMs(goal: AppState['goal']): number | undefined { if (goal === null || goal === undefined) return undefined; if (goal.status !== 'active') return goal.wallClockMs; diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 39ed97c49..72b1e94b1 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -9,9 +9,21 @@ * the edge and adding them would just churn the diff renderer. */ -import { Container } from '@earendil-works/pi-tui'; +import { Container } from '@moonshot-ai/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; + +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -19,15 +31,56 @@ export class GutterContainer extends Container { super(); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const inner = Math.max(1, width - this.leftPad - this.rightPad); const lead = ' '.repeat(this.leftPad); - const out: string[] = []; + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; for (const child of this.children) { - for (const line of child.render(inner)) { - out.push(lead + line); + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + prefixed.push(lines.map((line) => lead + line)); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); } } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + return out; } } diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 0f9c971ea..a9d21452e 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -1,5 +1,5 @@ -import { Text } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -7,6 +7,7 @@ import { MOON_SPINNER_FRAMES, MOON_SPINNER_INTERVAL_MS, } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type SpinnerStyle = 'moon' | 'braille'; @@ -19,6 +20,14 @@ 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; constructor( ui: TUI, @@ -50,6 +59,10 @@ export class MoonLoader extends Text { } } + dispose(): void { + this.stop(); + } + setLabel(label: string): void { this.label = label; this.updateDisplay(); @@ -60,14 +73,34 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + 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; - this.displayText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + 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); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; this.setText(this.displayText); this.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 45ed12c83..b101b6d6c 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -9,10 +9,11 @@ * is issued. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; export type TodoStatus = 'pending' | 'in_progress' | 'done'; @@ -27,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record<TodoStatus, number>; } /** @@ -48,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[] = []; @@ -90,19 +96,24 @@ 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, }; } export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; - private colors: ColorPalette; - - constructor(colors: ColorPalette) { - this.colors = colors; - } + private expanded = false; setTodos(todos: readonly TodoItem[]): void { this.todos = todos.map((t) => ({ title: t.title, status: t.status })); @@ -114,31 +125,57 @@ export class TodoPanelComponent implements Component { clear(): void { this.todos = []; + this.expanded = false; } isEmpty(): boolean { return this.todos.length === 0; } - setColors(colors: ColorPalette): void { - this.colors = colors; + /** True when the list exceeds the collapsed cap, i.e. there is something to expand. */ + hasOverflow(): boolean { + return this.todos.length > MAX_VISIBLE; + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + toggleExpanded(): void { + this.expanded = !this.expanded; } invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return []; - const c = this.colors; - const { rows, hidden } = selectVisibleTodos(this.todos); + const c = currentTheme.palette; const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), - chalk.hex(c.primary).bold(' Todo'), + chalk.hex(c.primary).bold(' Todo'), ]; - for (const todo of rows) { - lines.push(renderRow(todo, c)); - } - if (hidden > 0) { - lines.push(chalk.hex(c.textDim)(` … +${hidden} more`)); + + if (this.expanded) { + for (const todo of this.todos) { + lines.push(renderRow(todo, c)); + } + if (this.todos.length > MAX_VISIBLE) { + lines.push( + chalk.hex(c.textDim)(` all ${String(this.todos.length)} items · ctrl+t to collapse`), + ); + } + } else { + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); + for (const todo of rows) { + lines.push(renderRow(todo, c)); + } + if (hidden > 0) { + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); + } } return lines.map((line) => truncateToWidth(line, width)); @@ -172,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(' · '); +} diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 0f8a0b05e..10cdecbdb 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -3,28 +3,46 @@ * Renders a round-bordered box with the logo, session, model, and version. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; + import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { currentTheme } from '#/tui/theme'; export class WelcomeComponent implements Component { private state: AppState; - private colors: ColorPalette; - constructor(state: AppState, colors: ColorPalette) { + constructor(state: AppState) { this.state = state; - this.colors = colors; } invalidate(): void {} render(width: number): string[] { - const primary = (s: string): string => chalk.hex(this.colors.primary)(s); - const innerWidth = Math.max(10, width - 4); + const safeWidth = Math.max(0, width); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); + const isLoggedOut = !this.state.model; + const activeModel = this.state.availableModels[this.state.model]; + const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); + + if (safeWidth < 24) { + const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const prompt = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') + : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); + const model = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + return ['', title, prompt, `Model: ${model}`].map((line) => + truncateToWidth(line, safeWidth, '…'), + ); + } + + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; // Logo + side-by-side text. @@ -34,13 +52,12 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(this.colors.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), textWidth, '…', ); - const isLoggedOut = !this.state.model; - const dim = chalk.hex(this.colors.textDim); - const labelStyle = chalk.bold.hex(this.colors.textDim); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); const rightRow1 = truncateToWidth( dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), textWidth, @@ -52,13 +69,12 @@ export class WelcomeComponent implements Component { primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, ]; if (isRainbowDancing()) { - renderedHeaderLines = renderDanceWelcomeHeader(this.colors, logo, textWidth, rightRow1); + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); } - const activeModel = this.state.availableModels[this.state.model]; const modelValue = isLoggedOut - ? chalk.hex(this.colors.warning)('not set, run /login or /provider') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); const infoLines = [ labelStyle('Directory: ') + this.state.workDir, @@ -75,8 +91,8 @@ export class WelcomeComponent implements Component { const lines: string[] = [ '', - primary('╭' + '─'.repeat(width - 2) + '╮'), - primary('│') + ' '.repeat(width - 2) + primary('│'), + primary('╭' + '─'.repeat(safeWidth - 2) + '╮'), + primary('│') + ' '.repeat(safeWidth - 2) + primary('│'), ]; for (const content of contentLines) { @@ -86,10 +102,10 @@ export class WelcomeComponent implements Component { lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); } - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); - lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/chrome/working-tips.ts b/apps/kimi-code/src/tui/components/chrome/working-tips.ts new file mode 100644 index 000000000..23d002738 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,31 @@ +import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips'; + +import { buildWeightedTips } from './footer'; + +export { WORKING_TIPS }; + +const TIP_ROTATE_INTERVAL_MS = 10_000; + +const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS); + +export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length; + return WORKING_TIP_ROTATION[index]; +} + +/** + * Pick a random tip from the weighted working-tip rotation. + * If `excludeText` is provided and there are other tips available, avoid + * returning the same text twice in a row. + */ +export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const candidates = + excludeText === undefined || WORKING_TIP_ROTATION.length === 1 + ? WORKING_TIP_ROTATION + : WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText); + const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION; + const index = Math.floor(Math.random() * pool.length); + return pool[index]; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 2a06a4278..d95d9ff93 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -6,10 +6,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } @@ -47,7 +46,6 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly input = new Input(); private readonly onDone: (result: ApiKeyInputResult) => void; - private readonly colors: ColorPalette; private readonly title: string; private readonly subtitleLines: readonly string[]; private done = false; @@ -57,11 +55,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - colors: ColorPalette, ) { super(); this.onDone = onDone; - this.colors = colors; this.title = `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; this.input.onSubmit = (value) => { @@ -93,17 +89,18 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { override render(width: number): string[] { this.input.focused = this.focused && !this.done; - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', this.title); const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => - truncateToWidth(chalk.hex(this.colors.textDim)(line), innerWidth, '…'), + truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); @@ -120,6 +117,10 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { footerLine, ]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + const lines: string[] = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -136,7 +137,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private submit(value: string): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index ad92e0326..e18f5709b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -14,9 +14,8 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { @@ -26,7 +25,6 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; @@ -50,13 +48,13 @@ interface BlockStyles { errorBold: (s: string) => string; } -function makeBlockStyles(colors: ColorPalette): BlockStyles { +function makeBlockStyles(): BlockStyles { return { - strong: (s) => chalk.hex(colors.textStrong)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - accent: (s) => chalk.hex(colors.accent)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - errorBold: (s) => chalk.bold.hex(colors.error)(s), + strong: (s) => currentTheme.fg('textStrong', s), + dim: (s) => currentTheme.fg('textDim', s), + accent: (s) => currentTheme.fg('accent', s), + gutter: (s) => currentTheme.fg('diffGutter', s), + errorBold: (s) => currentTheme.boldFg('error', s), }; } @@ -105,12 +103,11 @@ function renderShellDisplayBlock( function renderDisplayBlock( block: DisplayBlock, s: BlockStyles, - colors: ColorPalette, contentWidth: number, ): string[] { switch (block.type) { case 'diff': - return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, { + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, { contextLines: 3, expandKeyHint: 'ctrl+e to preview', maxLines: DIFF_SUMMARY_MAX_LINES, @@ -215,7 +212,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { private readonly feedbackInput = new Input(); private onResponse: (response: ApprovalPanelResponse) => void; private request: PendingApproval; - private readonly colors: ColorPalette; private readonly onToggleToolOutput: (() => void) | undefined; private readonly onOpenPreview: | ((block: DiffDisplayBlock | FileContentDisplayBlock) => void) @@ -224,14 +220,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { constructor( request: PendingApproval, onResponse: (response: ApprovalPanelResponse) => void, - colors: ColorPalette, onToggleToolOutput?: () => void, onOpenPreview?: (block: DiffDisplayBlock | FileContentDisplayBlock) => void, ) { super(); this.request = request; this.onResponse = onResponse; - this.colors = colors; this.onToggleToolOutput = onToggleToolOutput; this.onOpenPreview = onOpenPreview; this.feedbackInput.onSubmit = (value) => { @@ -328,12 +322,12 @@ export class ApprovalPanelComponent extends Container implements Focusable { this.ensureValidSelection(); this.feedbackInput.focused = this.focused && this.feedbackMode; const { data } = this.request; - const blockStyles = makeBlockStyles(this.colors); - const borderColor = chalk.hex(this.colors.borderFocus); - const borderColorBold = chalk.bold.hex(this.colors.borderFocus); - const selectColorBold = chalk.bold.hex(this.colors.accent); - const dim = chalk.hex(this.colors.textDim); - const strong = chalk.hex(this.colors.textStrong); + const blockStyles = makeBlockStyles(); + const borderColor = (text: string) => currentTheme.fg('borderFocus', text); + const borderColorBold = (text: string) => currentTheme.boldFg('borderFocus', text); + const selectColorBold = (text: string) => currentTheme.boldFg('accent', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const strong = (text: string) => currentTheme.fg('textStrong', text); const horizontalBar = borderColor('─'.repeat(width)); const indent = (s: string): string => ` ${s}`; @@ -357,7 +351,6 @@ export class ApprovalPanelComponent extends Container implements Focusable { const blockLines = renderDisplayBlock( block, blockStyles, - this.colors, Math.max(1, width - 2), ); for (const line of blockLines) { @@ -386,6 +379,18 @@ export class ApprovalPanelComponent extends Container implements Focusable { } else { lines.push(indent(strong(` ${labelWithNum}`))); } + + // Optional helper text under the label, aligned past the pointer/number. + // Choices without a description render exactly as before. + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.feedbackMode && option.requires_feedback === true && isSelected) + ) { + for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) { + lines.push(indent(` ${dim(descLine)}`)); + } + } } lines.push(''); @@ -433,8 +438,7 @@ export class ApprovalPanelComponent extends Container implements Focusable { } private renderInlineFeedbackLine(width: number, labelWithNum: string): string { - const selectColorBold = chalk.bold.hex(this.colors.accent); - const prefix = `${selectColorBold('▶')} ${selectColorBold(labelWithNum)} `; + const prefix = `${currentTheme.boldFg('accent', '▶')} ${currentTheme.boldFg('accent', labelWithNum)} `; const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 7853f7e23..044884792 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -24,13 +24,12 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; -import { renderDiffLines } from '#/tui/components/media/diff-preview'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -39,7 +38,6 @@ export type ApprovalPreviewBlock = DiffDisplayBlock | FileContentDisplayBlock; export interface ApprovalPreviewViewerProps { readonly block: ApprovalPreviewBlock; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -62,9 +60,9 @@ export class ApprovalPreviewViewer extends Container implements Focusable { private readonly props: ApprovalPreviewViewerProps; private readonly terminal: Terminal; /** Pre-rendered body lines (ANSI-styled, no border / no gutter). */ - private readonly bodyLines: string[]; + private bodyLines: string[]; /** Title shown in the header (path + diff stats / "Write" label). */ - private readonly headerTitle: string; + private headerTitle: string; /** Index of the topmost visible line. */ private scrollTop = 0; @@ -72,7 +70,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { super(); this.props = props; this.terminal = terminal; - const built = buildBody(props.block, props.colors); + const built = buildBody(props.block); this.bodyLines = built.lines; this.headerTitle = built.title; } @@ -98,11 +96,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '') { + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '') { + if (matchesKey(data, Key.pageDown) || data === '\x06') { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -120,9 +118,15 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollTo(this.scrollTop + delta); } + override invalidate(): void { + const built = buildBody(this.props.block); + this.bodyLines = built.lines; + this.headerTitle = built.title; + } + private scrollTo(target: number): void { this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); - this.invalidate(); + super.invalidate(); } private maxScroll(): number { @@ -146,14 +150,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Preview '); + const title = currentTheme.boldFg('primary', ' Preview '); return fitExactly(title + this.headerTitle, width); } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; const innerWidth = Math.max(1, width - 4); const max = this.maxScroll(); @@ -161,23 +162,22 @@ export class ApprovalPreviewViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.bodyLines[lineIndex] ?? ''; - out.push(chalk.hex(stroke)('│ ') + fitExactly(raw, innerWidth) + chalk.hex(stroke)(' │')); + out.push(currentTheme.fg('primary', '│ ') + fitExactly(raw, innerWidth) + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.bodyLines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -186,7 +186,8 @@ export class ApprovalPreviewViewer extends Container implements Focusable { const lineFrom = total === 0 ? 0 : this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = @@ -209,39 +210,39 @@ interface BuiltBody { title: string; } -function buildBody(block: ApprovalPreviewBlock, colors: ColorPalette): BuiltBody { +function buildBody(block: ApprovalPreviewBlock): BuiltBody { if (block.type === 'diff') { - return buildDiffBody(block, colors); + return buildDiffBody(block); } - return buildFileContentBody(block, colors); + return buildFileContentBody(block); } -function buildDiffBody(block: DiffDisplayBlock, colors: ColorPalette): BuiltBody { - // renderDiffLines emits a `+N -M path` header on its first line followed - // by every changed line. We pull the header out into the viewer chrome so - // the body is purely scrollable diff content; this also means we don't - // double-render the path. - const rendered = renderDiffLines( +function buildDiffBody(block: DiffDisplayBlock): BuiltBody { + // renderDiffLinesClustered emits a `+N -M path` header on its first line + // followed by every changed line plus surrounding context. We pull the + // header out into the viewer chrome so the body is purely scrollable diff + // content; this also means we don't double-render the path. + const rendered = renderDiffLinesClustered( block.old_text, block.new_text, block.path, - colors, - false, - block.old_start ?? 1, - block.new_start ?? 1, + { + contextLines: 3, + oldStart: block.old_start ?? 1, + newStart: block.new_start ?? 1, + }, ); const [header = '', ...rest] = rendered; return { lines: rest, title: stripLeadingSpace(header) }; } -function buildFileContentBody(block: FileContentDisplayBlock, colors: ColorPalette): BuiltBody { +function buildFileContentBody(block: FileContentDisplayBlock): BuiltBody { const lang = block.language ?? langFromPath(block.path); const highlighted = highlightLines(block.content, lang); - const gutter = chalk.hex(colors.diffGutter); const lines = highlighted.map( - (line, i) => gutter(String(i + 1).padStart(4) + ' ') + line, + (line, i) => currentTheme.fg('diffGutter', String(i + 1).padStart(4) + ' ') + line, ); - const title = chalk.hex(colors.textStrong)(block.path); + const title = currentTheme.fg('textStrong', block.path); return { lines, title }; } diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index d5375d776..b6b74fe5b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -15,11 +15,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +} from '@moonshot-ai/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -32,21 +30,28 @@ 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 { readonly title: string; readonly hint?: string; - readonly formatHint?: (text: string, colors: ColorPalette) => 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; - readonly colors: ColorPalette; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate. */ readonly pageSize?: number; readonly onSelect: (value: string) => void; + /** When provided, Alt+S invokes this with the selected value instead of + * onSelect — used to apply the choice to the current session only. */ + readonly onSessionOnlySelect?: (value: string) => void; readonly onCancel: () => void; } @@ -97,6 +102,11 @@ export class ChoicePickerComponent extends Container implements Focusable { this.opts.onCancel(); return; } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value); + return; + } // Left/Right page through the list (this picker has no horizontal control). if (matchesKey(data, Key.left)) { this.list.pageUp(); @@ -118,7 +128,6 @@ export class ChoicePickerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const options = view.items; @@ -132,41 +141,54 @@ export class ChoicePickerComponent extends Container implements Focusable { const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = - searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix, - this.opts.formatHint === undefined - ? chalk.hex(colors.textMuted)(` ${hint}`) - : this.opts.formatHint(` ${hint}`, colors), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } if (this.opts.notice !== undefined) { - lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`)); + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } + } } lines.push(''); if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (options.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { const opt = options[i]!; const isSelected = i === view.selectedIndex; const isCurrent = opt.value === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; - const labelStyle = optionLabelStyle(opt, isSelected, colors); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = optionLabelStyle(opt, isSelected); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); line += labelStyle(opt.label); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } @@ -174,12 +196,12 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(''); if (view.page.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg('textMuted', ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, ), ); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } } @@ -187,10 +209,13 @@ export class ChoicePickerComponent extends Container implements Focusable { function optionLabelStyle( option: ChoiceOption, selected: boolean, - colors: ColorPalette, ): (text: string) => string { if (option.tone === 'danger') { - return selected ? chalk.hex(colors.error).bold : chalk.hex(colors.error); + return selected + ? (text) => currentTheme.boldFg('error', text) + : (text) => currentTheme.fg('error', text); } - return selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + return selected + ? (text) => currentTheme.boldFg('primary', text) + : (text) => currentTheme.fg('text', text); } diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 6a55ede98..9ade9350c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -13,50 +13,90 @@ * reads the same "work in progress" signal across the UI. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { - private readonly colors: ColorPalette; private readonly ui: TUI | undefined; private readonly headerText: Text; + private instructionText: Text | undefined; + private readonly instruction: string | undefined; + private readonly tip: string | undefined; private blinkOn = true; private blinkTimer: ReturnType<typeof setInterval> | null = null; private done = false; private canceled = false; private tokensBefore: number | undefined; private tokensAfter: number | undefined; + private summary: string | undefined; + private summaryText: Text | undefined; + private expanded = false; - constructor(colors: ColorPalette, ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); - this.colors = colors; this.ui = ui; + this.instruction = instruction; + this.tip = tip; // Top margin so the block isn't glued to the previous transcript // entry (status line, tool result, etc.). this.addChild(new Spacer(1)); this.headerText = new Text(this.buildHeader(), 0, 0); this.addChild(this.headerText); - if (instruction !== undefined) { - this.addChild(new Text(chalk.dim(` ${instruction}`), 0, 0)); - } + this.addInstructionChild(); this.startBlink(); } - markDone(tokensBefore?: number, tokensAfter?: number): void { + private addInstructionChild(): void { + if (this.instruction !== undefined) { + this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0); + this.addChild(this.instructionText); + } + } + + private removeInstructionChild(): void { + if (this.instructionText === undefined) return; + const index = this.children.indexOf(this.instructionText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.instructionText = undefined; + } + + override invalidate(): void { + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // Rebuild instruction and summary text with fresh theme colours, preserving + // header → instruction → summary child order. + const expanded = this.expanded; + this.removeInstructionChild(); + if (expanded) { + this.removeSummaryChild(); + } + this.addInstructionChild(); + if (expanded) { + this.addSummaryChild(); + } + super.invalidate(); + } + + markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void { if (this.done || this.canceled) return; this.done = true; this.tokensBefore = tokensBefore; this.tokensAfter = tokensAfter; + this.summary = summary; this.stopBlink(); this.headerText.setText(this.buildHeader()); + if (this.expanded) { + this.addSummaryChild(); + } this.ui?.requestRender(); } @@ -68,28 +108,66 @@ export class CompactionComponent extends Container { this.ui?.requestRender(); } + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + if (expanded) { + this.addSummaryChild(); + } else { + this.removeSummaryChild(); + } + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + private addSummaryChild(): void { + if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { + return; + } + const indentedSummary = this.summary + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0); + this.addChild(this.summaryText); + } + + private removeSummaryChild(): void { + if (this.summaryText === undefined) return; + const index = this.children.indexOf(this.summaryText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.summaryText = undefined; + } + dispose(): void { this.stopBlink(); } private buildHeader(): string { if (this.done) { - const bullet = chalk.hex(this.colors.success)(STATUS_BULLET); - const label = chalk.hex(this.colors.success).bold('Compaction complete'); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); const detail = this.tokensBefore !== undefined && this.tokensAfter !== undefined - ? chalk.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; - return `${bullet}${label}${detail}`; + const shortcutHint = + this.summary !== undefined && this.summary.length > 0 + ? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`) + : ''; + return `${bullet}${label}${detail}${shortcutHint}`; } if (this.canceled) { - const bullet = chalk.hex(this.colors.warning)(STATUS_BULLET); - const label = chalk.hex(this.colors.warning).bold('Compaction cancelled'); + const bullet = currentTheme.fg('warning', STATUS_BULLET); + const label = currentTheme.boldFg('warning', 'Compaction cancelled'); return `${bullet}${label}`; } - const bullet = this.blinkOn ? chalk.hex(this.colors.roleAssistant)(STATUS_BULLET) : ' '; - const label = chalk.hex(this.colors.primary).bold('Compacting context...'); - return `${bullet}${label}`; + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context...'); + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; + return `${bullet}${label}${tip}`; } private startBlink(): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index ec2f1d389..aa4aa910b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -17,10 +17,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface CustomRegistryImportValue { readonly url: string; @@ -54,7 +53,7 @@ function maskInputLine(raw: string): string { // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) // while masking every other visible character. - const parts = content.split(/((?:\[[0-9;]*m|_pi:c))/); + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); const maskedContent = parts .map((part, index) => { if (index % 2 === 1) return part; // ANSI sequence @@ -71,19 +70,16 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo private readonly urlInput = new Input(); private readonly tokenInput = new Input(); private readonly onDone: (result: CustomRegistryImportResult) => void; - private readonly colors: ColorPalette; private activeField: FieldId = 'url'; private done = false; private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; constructor( onDone: (result: CustomRegistryImportResult) => void, - colors: ColorPalette, defaultUrl: string = '', ) { super(); this.onDone = onDone; - this.colors = colors; if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl); // Enter on the URL field advances to the token field; Enter on the token // (last) field submits. @@ -141,20 +137,22 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo this.urlInput.focused = dialogActive && this.activeField === 'url'; this.tokenInput.focused = dialogActive && this.activeField === 'token'; - const safeWidth = Math.max(36, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.hint === 'url-empty' ? SUBTITLE_URL_EMPTY : this.hint === 'token-empty' ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)( + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg( + 'textDim', this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, ); @@ -162,12 +160,12 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo const tokenLabelText = 'Bearer token'; const urlLabelStyled = this.activeField === 'url' - ? chalk.bold.hex(this.colors.accent)(urlLabelText) - : chalk.hex(this.colors.textDim)(urlLabelText); + ? currentTheme.boldFg('accent', urlLabelText) + : currentTheme.fg('textDim', urlLabelText); const tokenLabelStyled = this.activeField === 'token' - ? chalk.bold.hex(this.colors.accent)(tokenLabelText) - : chalk.hex(this.colors.textDim)(tokenLabelText); + ? currentTheme.boldFg('accent', tokenLabelText) + : currentTheme.fg('textDim', tokenLabelText); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); @@ -192,6 +190,10 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo footerLine, ]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; + } + const lines: string[] = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -208,7 +210,7 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private toggleField(): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts index 24467dd05..9e98a457b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const EDITOR_OPTIONS: readonly ChoiceOption[] = [ { value: 'code --wait', label: 'VS Code (code --wait)' }, { value: 'vim', label: 'Vim' }, @@ -12,7 +10,6 @@ const EDITOR_OPTIONS: readonly ChoiceOption[] = [ export interface EditorSelectorOptions { readonly currentValue: string; - readonly colors: ColorPalette; readonly onSelect: (value: string) => void; readonly onCancel: () => void; } @@ -23,7 +20,6 @@ export class EditorSelectorComponent extends ChoicePickerComponent { title: 'Select external editor', options: [...EDITOR_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts new file mode 100644 index 000000000..2678899ad --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -0,0 +1,94 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; + +import { effortLabel } from './model-selector'; + +export interface EffortSelectorOptions { + readonly title?: string; + /** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */ + readonly efforts: readonly ThinkingEffort[]; + /** Currently active effort (highlighted). */ + readonly currentValue: ThinkingEffort; + readonly onSelect: (effort: ThinkingEffort) => void; + /** When provided, Alt+S applies the choice to the current session only. */ + readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; + readonly onCancel: () => void; +} + +/** + * Horizontal segmented picker for the `/effort` command. + * + * Mirrors the thinking control rendered under `/model` (see + * `renderThinkingControl` in model-selector.ts): a single row of segments, + * the active one wrapped in `[ ]`. ←/→ step the active segment, Enter + * commits, and Alt+S (when provided) applies session-only. + */ +export class EffortSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: EffortSelectorOptions; + private activeIndex: number; + + constructor(opts: EffortSelectorOptions) { + super(); + this.opts = opts; + const idx = opts.efforts.indexOf(opts.currentValue); + this.activeIndex = Math.max(idx, 0); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.left)) { + this.activeIndex = Math.max(0, this.activeIndex - 1); + return; + } + if (matchesKey(data, Key.right)) { + this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!); + return; + } + if (matchesKey(data, Key.enter)) { + this.opts.onSelect(this.opts.efforts[this.activeIndex]!); + return; + } + } + + override render(width: number): string[] { + const hintParts = ['←→ switch', 'Enter select']; + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + '', + ]; + + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index c7dcb8da6..1e466f873 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -5,12 +5,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -23,7 +22,6 @@ export interface ExperimentalFeatureDraftChange { export interface ExperimentsSelectorOptions { readonly features: readonly ExperimentalFeatureState[]; - readonly colors: ColorPalette; readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; readonly onCancel: () => void; } @@ -66,28 +64,27 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const titleSuffix = - view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const hintParts = ['↑↓ navigate']; if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); if (view.query.length > 0) hintParts.push('Backspace clear'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Experimental features') + titleSuffix, - chalk.hex(colors.textMuted)(` ${hintParts.join(' · ')}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Experimental features') + titleSuffix, + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), '', ]; if (view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ` Search: `) + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } for (let i = view.page.start; i < view.page.end; i++) { @@ -99,19 +96,21 @@ export class ExperimentsSelectorComponent extends Container implements Focusable lines.push(''); if (view.query.length > 0) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, ), ); } else if (view.page.end < view.items.length) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` ▼ ${String(view.items.length - view.page.end)} more`, ), ); } lines.push(this.renderApplyButton()); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -145,15 +144,18 @@ export class ExperimentsSelectorComponent extends Container implements Focusable } private renderApplyButton(): string { - const { colors } = this.opts; const changes = this.draftChanges(); const count = changes.length; const label = '[ Apply changes and reload ]'; const summary = count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; - const buttonStyle = count === 0 ? chalk.hex(colors.textDim) : chalk.hex(colors.primary).bold; - const summaryStyle = count === 0 ? chalk.hex(colors.textMuted) : chalk.hex(colors.success); - return ` ${buttonStyle(label)} ${summaryStyle(summary)}`; + const button = count === 0 + ? currentTheme.fg('textDim', label) + : currentTheme.boldFg('primary', label); + const summaryText = count === 0 + ? currentTheme.fg('textMuted', summary) + : currentTheme.fg('success', summary); + return ` ${button} ${summaryText}`; } private renderFeature( @@ -161,23 +163,22 @@ export class ExperimentsSelectorComponent extends Container implements Focusable selected: boolean, width: number, ): string[] { - const { colors } = this.opts; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const label = selected ? currentTheme.boldFg('primary', feature.title) : currentTheme.fg('text', feature.title); const enabled = this.effectiveEnabled(feature); const status = enabled ? 'enabled' : 'disabled'; - const statusStyle = enabled ? chalk.hex(colors.success) : chalk.hex(colors.textDim); + const statusText = enabled ? currentTheme.fg('success', status) : currentTheme.fg('textDim', status); const detail = this.isDraftChanged(feature) ? `${featureDetail(feature)} · modified` : featureDetail(feature); const lines = [ - `${prefix}${labelStyle(feature.title)} ${statusStyle(status)}`, - chalk.hex(colors.textMuted)(` ${detail}`), + `${prefix}${label} ${statusText}`, + currentTheme.fg('textMuted', ` ${detail}`), ]; const descriptionWidth = Math.max(1, width - 4); for (const line of wrapText(feature.description, descriptionWidth)) { - lines.push(chalk.hex(colors.textMuted)(` ${line}`)); + lines.push(currentTheme.fg('textMuted', ` ${line}`)); } return lines; } diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 1fb963625..c1f108dd7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -5,6 +5,10 @@ * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with * the OAuth login flow. The box embeds a `pi-tui` Input for the actual * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). */ import { @@ -15,10 +19,8 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } @@ -34,14 +36,12 @@ export class FeedbackInputDialogComponent extends Container implements Focusable private readonly input = new Input(); private readonly onDone: (result: FeedbackInputDialogResult) => void; - private readonly colors: ColorPalette; private done = false; private emptyHinted = false; - constructor(onDone: (result: FeedbackInputDialogResult) => void, colors: ColorPalette) { + constructor(onDone: (result: FeedbackInputDialogResult) => void) { super(); this.onDone = onDone; - this.colors = colors; this.input.onSubmit = (value) => { this.submit(value); }; @@ -71,22 +71,35 @@ export class FeedbackInputDialogComponent extends Container implements Focusable override render(width: number): string[] { this.input.focused = this.focused && !this.done; - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const border = (s: string): string => chalk.hex(this.colors.primary)(s); - const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; - const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); - const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + const subtitleStyled = currentTheme.fg('textDim', subtitleText); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); 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, '…'))]; + } const lines: string[] = [ '', @@ -104,7 +117,7 @@ export class FeedbackInputDialogComponent extends Container implements Focusable lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } private submit(value: string): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index bf5f72356..b5c2e7ac1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; @@ -15,7 +15,7 @@ import type { GoalQueueSnapshot, UpcomingGoal, } from '#/tui/goal-queue-store'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -42,7 +42,6 @@ export type GoalQueueManagerAction = export interface GoalQueueManagerOptions { readonly goals: readonly UpcomingGoal[]; readonly selectedGoalId?: string; - readonly colors: ColorPalette; readonly pageSize?: number; readonly onAction: ( action: GoalQueueManagerAction, @@ -56,7 +55,6 @@ export type GoalQueueEditResult = export interface GoalQueueEditDialogOptions { readonly goal: UpcomingGoal; - readonly colors: ColorPalette; readonly onDone: (result: GoalQueueEditResult) => void; } @@ -115,20 +113,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const view = this.list.view(); const hint = this.movingGoalId === undefined ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Upcoming goals'), - chalk.hex(colors.textMuted)(` ${hint}`), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Upcoming goals'), + currentTheme.fg('textMuted', ` ${hint}`), '', ]; if (this.goals.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No upcoming goals.')); + lines.push(currentTheme.fg('textMuted', ' No upcoming goals.')); } else { for (let i = view.page.start; i < view.page.end; i++) { const goal = view.items[i]; @@ -139,20 +136,19 @@ export class GoalQueueManagerComponent extends Container implements Focusable { const below = view.items.length - view.page.end; if (below > 0) { lines.push(''); - lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { - const { colors } = this.opts; const moving = goal.id === this.movingGoalId; const pointer = selected ? SELECT_POINTER : ' '; - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); const labelPrefix = `${String(index + 1)}. `; const stateLabel = moving ? ' selected' : ''; const labelWidth = visibleWidth(labelPrefix); @@ -163,9 +159,11 @@ export class GoalQueueManagerComponent extends Container implements Focusable { objectiveWidth, ELLIPSIS, ); - const textStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const textStyle = selected + ? (text: string) => currentTheme.boldFg('primary', text) + : (text: string) => currentTheme.fg('text', text); let line = prefix + textStyle(labelPrefix + objective); - if (moving) line += chalk.hex(colors.success)(stateLabel); + if (moving) line += currentTheme.fg('success', stateLabel); return line; } @@ -243,18 +241,19 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable override render(width: number): string[] { this.input.focused = this.focused && !this.done; - const safeWidth = Math.max(28, width); - const innerWidth = Math.max(10, safeWidth - 4); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); const pad = ' '; - const { colors } = this.opts; - const border = (s: string): string => chalk.hex(colors.primary)(s); + const border = (s: string): string => currentTheme.fg('primary', s); const title = truncateToWidth( - chalk.hex(colors.textStrong).bold('Edit upcoming goal'), + currentTheme.boldFg('textStrong', 'Edit upcoming goal'), innerWidth, ELLIPSIS, ); const subtitle = truncateToWidth( - chalk.hex(this.error === undefined ? colors.textDim : colors.warning)( + currentTheme.fg( + this.error === undefined ? 'textDim' : 'warning', this.error ?? 'Update the queued objective.', ), innerWidth, @@ -262,11 +261,15 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable ); const inputLines = this.input.render(innerWidth); const footer = truncateToWidth( - chalk.hex(colors.textDim)('Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), + currentTheme.fg('textDim', 'Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), innerWidth, ELLIPSIS, ); const contentLines = [title, '', subtitle, '', ...inputLines, '', footer]; + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS))]; + } + const lines = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -282,7 +285,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); lines.push(''); - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS)); } private submit(value: string): void { diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index ade2ba69e..e60d85ce0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -1,5 +1,3 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, @@ -8,13 +6,12 @@ import { export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; export interface GoalStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; } -const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -40,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -const YOLO_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -60,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [ }, ]; +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + const MANUAL_NOTICE_LINES = [ 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', @@ -75,7 +80,6 @@ const YOLO_NOTICE_LINES = [ export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: GoalStartPermissionPromptOptions) { super({ - colors: opts.colors, title: opts.mode === 'yolo' ? 'Start a goal in YOLO mode?' diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 9b219a0c4..10fd5d5fe 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -15,10 +15,8 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { readonly keys: string; @@ -35,7 +33,8 @@ export interface HelpPanelCommand { export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ { keys: 'Shift-Tab', description: 'Toggle plan mode' }, { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, + { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, + { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, @@ -48,7 +47,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ export interface HelpPanelOptions { readonly commands: readonly HelpPanelCommand[]; readonly shortcuts?: readonly KeyboardShortcut[]; - readonly colors: ColorPalette; readonly onClose: () => void; /** Terminal height — used to decide whether to show the hint tail. */ readonly maxVisible?: number; @@ -93,12 +91,11 @@ export class HelpPanelComponent extends Container implements Focusable { } override render(width: number): string[] { - const colors = this.opts.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const muted = chalk.hex(colors.textMuted); - const kbdColor = chalk.hex(colors.warning); - const slashColor = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const muted = (text: string) => currentTheme.fg('textMuted', text); + const kbdColor = (text: string) => currentTheme.fg('warning', text); + const slashColor = (text: string) => currentTheme.fg('primary', text); const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); @@ -110,17 +107,17 @@ export class HelpPanelComponent extends Container implements Focusable { const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); const lines: string[] = [ accent('─'.repeat(width)), - accent.bold(' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, '', // Section: keyboard shortcuts - ` ${chalk.bold('Keyboard shortcuts')}`, + ` ${currentTheme.bold('Keyboard shortcuts')}`, ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), '', // Section: slash commands - ` ${chalk.bold('Slash commands')}`, + ` ${currentTheme.bold('Slash commands')}`, ...sortedCmds.map((cmd, i) => { const label = cmdLabels[i] ?? `/${cmd.name}`; return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index e9ba8c64d..82906d1d5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,4 +1,4 @@ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, @@ -6,12 +6,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -31,11 +30,15 @@ interface ModelChoice { export interface ModelSelection { readonly alias: string; - readonly thinking: boolean; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; } export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } export function providerDisplayName(provider: string): string { @@ -47,18 +50,22 @@ export function providerDisplayName(provider: string): string { export function createModelChoiceOptions( models: Record<string, ModelAlias>, ): readonly ChoiceOption[] { - return Object.entries(models).map(([alias, cfg]) => ({ - value: alias, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + return { + value: alias, + label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`, + }; + }); } export interface ModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; - readonly colors: ColorPalette; + /** Live thinking effort of the currently active model (e.g. 'off', 'on', + * 'high'). Used to highlight the active segment for the current model. */ + readonly currentThinkingEffort: ThinkingEffort; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -67,29 +74,77 @@ export interface ModelSelectorOptions { * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] { return Object.entries(models).map(([alias, cfg]) => { - const name = modelDisplayName(alias, cfg); - const provider = providerDisplayName(cfg.provider); - return { alias, model: cfg, name, provider, label: `${name} (${provider})` }; + const effective = effectiveModelAlias(cfg); + const name = modelDisplayName(alias, effective); + const provider = providerDisplayName(effective.provider); + return { alias, model: effective, name, provider, label: `${name} (${provider})` }; }); } -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { const caps = model.capabilities ?? []; if (caps.includes('always_thinking')) return 'always-on'; if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; return 'unsupported'; } -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} + +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); const availability = thinkingAvailability(model); - if (availability === 'always-on') return true; - if (availability === 'unsupported') return false; - return thinkingDraft; + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; } /** @@ -104,8 +159,8 @@ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; private readonly list: SearchableList<ModelChoice>; - /** Per-model thinking override set by ←/→; absent → the capability default. */ - private readonly thinkingOverrides = new Map<string, boolean>(); + /** Per-model thinking-effort override set by ←/→; absent → the default. */ + private readonly thinkingOverrides = new Map<string, string>(); constructor(opts: ModelSelectorOptions) { super(); @@ -123,15 +178,31 @@ export class ModelSelectorComponent extends Container implements Focusable { } /** - * Thinking draft for a model: an explicit ←/→ override when set, otherwise - * the live thinking state for the active model, otherwise On for any other - * thinking-capable model (a capable model should default to thinking on). + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort for the active model, otherwise the model's default effort + * (effort-capable) or 'on' (other thinking-capable models). */ - private draftFor(choice: ModelChoice): boolean { + private draftFor(choice: ModelChoice): string { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; - if (choice.alias === this.opts.currentValue) return this.opts.currentThinking; - return thinkingAvailability(choice.model) !== 'unsupported'; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; + const efforts = effortsOf(choice.model); + if (efforts.length > 0) { + // A model with support_efforts but no default_effort defaults to the + // middle entry of its supported efforts. + const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; + if (def !== undefined && efforts.includes(def)) return def; + return efforts[0]!; + } + return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; } handleInput(data: string): void { @@ -146,11 +217,27 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } - // Left/Right toggle the thinking draft for models that support it. + // Left/Right move the active thinking effort within the model's segments. if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { - this.thinkingOverrides.set(selected.alias, !this.draftFor(selected)); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } } return; } @@ -160,20 +247,29 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.draftFor(selected)), + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + return; + } + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), }); } } override render(width: number): string[] { - const { colors } = this.opts; const searchable = this.opts.searchable === true; const view = this.list.view(); const totalCount = Object.keys(this.opts.models).length; const titleSuffix = searchable && view.query.length === 0 - ? chalk.hex(colors.textMuted)(' (type to search)') + ? currentTheme.fg('textMuted', ' (type to search)') : ''; // "type to search" already lives in the title suffix, so the hint only @@ -182,21 +278,23 @@ export class ModelSelectorComponent extends Container implements Focusable { if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); hintParts.push('↑↓ navigate'); if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - hintParts.push('Enter select', 'Esc cancel'); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Select a model') + titleSuffix, - chalk.hex(colors.textMuted)(' ' + hintParts.join(' · ')), + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), '', ]; if (searchable && view.query.length > 0) { - lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); + lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); } if (view.items.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No matches')); + lines.push(currentTheme.fg('textMuted', ' No matches')); } else { // Column width for model names so the provider column lines up. Capped so // the provider + "← current" marker still fit on normal terminal widths. @@ -214,14 +312,13 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === view.selectedIndex; const isCurrent = choice.alias === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; - const nameStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); - let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); - line += nameStyle(truncatedName) + namePad; - line += ' ' + chalk.hex(colors.textMuted)(choice.provider); + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += (isSelected ? currentTheme.boldFg('primary', truncatedName) : currentTheme.fg('text', truncatedName)) + namePad; + line += ' ' + currentTheme.fg('textMuted', choice.provider); if (isCurrent) { - line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + line += ' ' + currentTheme.fg('success', CURRENT_MARK); } lines.push(line); } @@ -231,26 +328,26 @@ export class ModelSelectorComponent extends Container implements Focusable { if (view.query.length > 0) { lines.push(''); lines.push( - chalk.hex(colors.textMuted)(` ${String(view.items.length)} / ${String(totalCount)}`), + currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`), ); } else { const below = view.items.length - view.page.end; if (below > 0) { lines.push(''); - lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } lines.push(''); const selected = this.selectedChoice(); if (selected !== undefined) { - const availability = thinkingAvailability(selected.model); - const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; - lines.push(chalk.hex(colors.textMuted)(thinkingHeader)); + const canSwitch = segmentsFor(selected.model).length > 1; + const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; + lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); } lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } @@ -259,20 +356,29 @@ export class ModelSelectorComponent extends Container implements Focusable { } private renderThinkingControl(choice: ModelChoice): string { - const { colors } = this.opts; const segment = (label: string, active: boolean): string => active - ? chalk.hex(colors.primary).bold(`[ ${label} ]`) - : chalk.hex(colors.text)(` ${label} `); + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + // The whole segment is muted, suffix included, so the disabled side reads + // as a single greyed-out control rather than a selectable option. + const unavailable = (label: string): string => + currentTheme.fg('textMuted', ` ${label} (Unsupported) `); + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); const availability = thinkingAvailability(choice.model); - if (availability === 'always-on') { - return ` ${segment('Always on', true)}`; + if (efforts.length === 0 && availability === 'always-on') { + return ` ${segment('On', true)} ${unavailable('Off')}`; } - if (availability === 'unsupported') { - return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; + if (efforts.length === 0 && availability === 'unsupported') { + return ` ${unavailable('On')} ${segment('Off', true)}`; } - const draft = this.draftFor(choice); - return ` ${segment('On', draft)} ${segment('Off', !draft)}`; + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + return ` ${rendered.join(' ')}`; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 6445206d3..4b2db673d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -2,26 +2,21 @@ import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', label: 'Manual', - description: - 'Ask before commands, edits, and other risky actions. Read/search tools run directly; session approval rules are respected.', + description: 'Approve every action yourself.', }, { value: 'auto', label: 'Auto', - description: - 'Run fully non-interactively. Tool actions are approved automatically, and agent questions are skipped so it can decide on its own.', + description: 'Run all actions automatically, including risky ones.', }, { value: 'yolo', label: 'YOLO', - description: - 'Automatically approve tool actions and plan transitions. The agent can still ask you explicit questions when your input is needed.', + description: 'AI decides which actions need your approval.', }, ]; @@ -31,7 +26,6 @@ function isPermissionModeChoice(value: string): value is PermissionMode { export interface PermissionSelectorOptions { readonly currentValue: PermissionMode; - readonly colors: ColorPalette; readonly onSelect: (mode: PermissionMode) => void; readonly onCancel: () => void; } @@ -42,7 +36,6 @@ export class PermissionSelectorComponent extends ChoicePickerComponent { title: 'Select permission mode', options: [...PERMISSION_OPTIONS], currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { if (isPermissionModeChoice(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index c1d8a1467..a332f70af 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -2,15 +2,12 @@ import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), ]; export interface PlatformSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (platformId: string) => void; readonly onCancel: () => void; } @@ -20,7 +17,6 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { super({ title: 'Select a platform', options: [...PLATFORM_OPTIONS], - colors: opts.colors, onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 49091009e..d7066c77c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -1,32 +1,54 @@ import { Container, + Input, Key, matchesKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; -import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { ChoicePickerComponent } from './choice-picker'; -const OVERVIEW_MARKETPLACE = 'marketplace'; -const OVERVIEW_RELOAD = 'reload'; -const OVERVIEW_SHOW_LIST = 'show-list'; -const OVERVIEW_PLUGIN_PREFIX = 'plugin:'; 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 = '…'; +// Hardcoded Web Bridge promotion: a built-in entry that always leads the +// Official tab, even when the marketplace catalog is unavailable. Selecting it +// opens the install page in the browser rather than installing from a source, +// because Web Bridge is a browser extension + daemon, not a plugin package. +const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge'; +const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + source: WEB_BRIDGE_URL, + tier: 'official', + homepage: WEB_BRIDGE_URL, + description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot', +}; + +// Only the hardcoded pinned row should open the WebBridge install page. Match +// by reference (not id) so a catalog entry on another tab that happens to +// reuse the same id still installs normally instead of being hijacked. +function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry === WEB_BRIDGE_ENTRY; +} + interface PluginsOverviewItem { readonly value: string; readonly kind: 'plugin' | 'action'; @@ -35,253 +57,6 @@ interface PluginsOverviewItem { readonly description: string; } -export type PluginsOverviewSelection = - | { readonly kind: 'marketplace' } - | { readonly kind: 'reload' } - | { readonly kind: 'show-list' } - | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } - | { readonly kind: 'mcp'; readonly id: string } - | { readonly kind: 'remove'; readonly id: string } - | { readonly kind: 'info'; readonly id: string }; - -export interface PluginsOverviewSelectorOptions { - readonly plugins: readonly PluginSummary[]; - readonly selectedId?: string; - readonly pluginHint?: { - readonly id: string; - readonly text: string; - }; - readonly colors: ColorPalette; - readonly onSelect: (selection: PluginsOverviewSelection) => void; - readonly onCancel: () => void; -} - -export class PluginsOverviewSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginsOverviewSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - - constructor(opts: PluginsOverviewSelectorOptions) { - super(); - this.opts = opts; - this.items = buildOverviewItems(opts.plugins); - const selectedIndex = this.items.findIndex( - (item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`, - ); - this.selectedIndex = Math.max(0, selectedIndex); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - const pluginId = overviewItemPluginId(chosen); - const decoded = printableChar(data); - if (matchesKey(data, Key.space) || decoded === ' ') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined) { - this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled }); - } - return; - } - if (decoded === 'd' || decoded === 'D') { - if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId }); - return; - } - if (decoded === 'm' || decoded === 'M') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined && plugin.mcpServerCount > 0) { - this.opts.onSelect({ kind: 'mcp', id: pluginId }); - } - return; - } - if (matchesKey(data, Key.enter)) { - if (pluginId !== undefined) { - this.opts.onSelect({ kind: 'info', id: pluginId }); - return; - } - const selection = parseOverviewSelection(chosen.value); - if (selection !== undefined) this.opts.onSelect(selection); - } - } - - override render(width: number): string[] { - const { colors, plugins } = this.opts; - const hint = - '↑↓ navigate · Space toggle · M MCP servers · D remove · Enter details · Esc cancel'; - const pluginItems = this.items.filter((item) => item.kind === 'plugin'); - const actionItems = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Plugins'), - mutedHintLine(` ${hint}`, colors), - '', - sectionLabel(`Installed plugins (${plugins.length})`, colors), - ]; - - if (pluginItems.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); - } else { - let absoluteIndex = 0; - for (const item of pluginItems) { - lines.push(...this.renderItem(item, absoluteIndex, width)); - absoluteIndex++; - } - } - - lines.push(''); - lines.push(sectionLabel('Actions', colors)); - for (let i = 0; i < actionItems.length; i++) { - lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); - } - - lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; - const selected = index === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); - } - const pluginId = overviewItemPluginId(item); - if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); - } - - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); - } - return lines; - } -} - -export type PluginMarketplaceSelection = - | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { readonly kind: 'back' }; - -export interface PluginMarketplaceSelectorOptions { - readonly entries: readonly PluginMarketplaceEntry[]; - readonly installedIds: ReadonlySet<string>; - readonly source: string; - readonly colors: ColorPalette; - readonly onSelect: (selection: PluginMarketplaceSelection) => void; - readonly onCancel: () => void; -} - -export class PluginMarketplaceSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginMarketplaceSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - - constructor(opts: PluginMarketplaceSelectorOptions) { - super(); - this.opts = opts; - this.items = buildMarketplaceItems(opts.entries, opts.installedIds); - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.enter)) { - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - if (chosen.value === 'back') { - this.opts.onSelect({ kind: 'back' }); - return; - } - const entry = this.opts.entries.find((item) => item.id === chosen.value); - if (entry === undefined) return; - this.opts.onSelect({ kind: 'install', entry }); - } - } - - override render(width: number): string[] { - const { colors } = this.opts; - const entries = this.items.filter((item) => item.kind === 'plugin'); - const actions = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Official plugins'), - mutedHintLine(' ↑↓ navigate · Enter install/update · Esc cancel', colors), - chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`), - '', - sectionLabel(`Marketplace (${entries.length})`, colors), - ]; - - if (entries.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No marketplace plugins found.')); - } else { - for (let i = 0; i < entries.length; i++) { - lines.push(...this.renderItem(entries[i]!, i, width)); - } - } - - lines.push(''); - lines.push(sectionLabel('Actions', colors)); - for (let i = 0; i < actions.length; i++) { - lines.push(...this.renderItem(actions[i]!, entries.length + i, width)); - } - - lines.push(''); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; - const selected = index === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item, colors)(item.status); - } - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`, colors)); - } - return lines; - } -} - export type PluginMcpSelection = | { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean } | { readonly kind: 'back'; readonly pluginId: string }; @@ -293,7 +68,6 @@ export interface PluginMcpSelectorOptions { readonly server: string; readonly text: string; }; - readonly colors: ColorPalette; readonly onSelect: (selection: PluginMcpSelection) => void; readonly onCancel: () => void; } @@ -349,7 +123,8 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors, info } = this.opts; + const { info } = this.opts; + const colors = currentTheme.palette; const serverItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ @@ -380,7 +155,7 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const { colors } = this.opts; + const colors = currentTheme.palette; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); @@ -409,7 +184,6 @@ export type PluginRemoveConfirmResult = export interface PluginRemoveConfirmOptions { readonly id: string; readonly displayName: string; - readonly colors: ColorPalette; readonly onDone: (result: PluginRemoveConfirmResult) => void; } @@ -432,7 +206,6 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { description: 'Remove only the install record; plugin files are left in place.', }, ], - colors: opts.colors, onSelect: (value) => { opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' }); }, @@ -443,35 +216,53 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { } } -function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] { - const options: PluginsOverviewItem[] = plugins.map((plugin) => ({ - value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`, - kind: 'plugin', - label: plugin.displayName, - status: pluginStatus(plugin), - description: overviewPluginDescription(plugin), - })); - options.push( - { - value: OVERVIEW_MARKETPLACE, - kind: 'action', - label: 'Marketplace', - description: 'Browse official plugins.', - }, - { - value: OVERVIEW_RELOAD, - kind: 'action', - label: 'Reload', - description: 'Re-read installed plugins and manifests.', - }, - { - value: OVERVIEW_SHOW_LIST, - kind: 'action', - label: 'Summary', - description: 'Append the current plugin summary to the transcript.', - }, - ); - return options; +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 { @@ -487,41 +278,465 @@ function overviewPluginDescription(plugin: PluginSummary): string { return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`; } -function pluginStatus(plugin: PluginSummary): string { +function pluginStatus(plugin: PluginSummary): string | undefined { if (plugin.state !== 'ok') return plugin.state; return plugin.enabled ? 'enabled' : 'disabled'; } -function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined { - if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' }; - if (value === OVERVIEW_RELOAD) return { kind: 'reload' }; - if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' }; - return undefined; +function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { + // "update …" is a warning (actionable); "installed …" is success; + // "install …" is the available action. + if (status.startsWith('update')) return chalk.hex(colors.warning); + if (status.startsWith('installed')) return chalk.hex(colors.success); + return chalk.hex(colors.primary); } -function overviewItemPluginId(item: PluginsOverviewItem): string | undefined { - if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined; - return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length); +/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace + * Custom tab and the unified plugins panel. */ +function renderUrlInputBox( + input: Input, + focused: boolean, + width: number, + colors: ColorPalette, +): string[] { + input.focused = focused; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const boxWidth = Math.max(24, width - 2); + const innerWidth = Math.max(10, boxWidth - 4); + const inputLine = input.render(innerWidth)[0] ?? ''; + const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine)); + return [ + ' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'), + ' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'), + ' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'), + ]; } -function buildMarketplaceItems( - entries: readonly PluginMarketplaceEntry[], - installedIds: ReadonlySet<string>, -): PluginsOverviewItem[] { - const items: PluginsOverviewItem[] = entries.map((entry) => ({ - value: entry.id, - kind: 'plugin', - label: entry.displayName, - status: installedIds.has(entry.id) ? 'installed' : installStatus(entry), - description: marketplaceEntryDescription(entry), - })); - items.push({ - value: 'back', - kind: 'action', - label: 'Back to installed plugins', - description: 'Return to the local plugin manager.', - }); - return items; +// =========================================================================== +// Unified /plugins panel: Installed / Official / Third-party / Custom tabs. +// =========================================================================== + +export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom'; + +export type PluginsPanelSelection = + | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } + | { readonly kind: 'remove'; readonly id: string } + | { readonly kind: 'mcp'; readonly id: string } + | { readonly kind: 'details'; readonly id: string } + | { readonly kind: 'reload' } + | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; + +export interface PluginsPanelOptions { + readonly installed: readonly PluginSummary[]; + readonly installedIds: ReadonlySet<string>; + readonly initialTab?: PluginsPanelTabId; + readonly selectedId?: string; + readonly pluginHint?: { readonly id: string; readonly text: string }; + readonly onSelect: (selection: PluginsPanelSelection) => void; + readonly onCancel: () => void; + /** Called the first time the Official or Third-party tab needs its catalog. + * The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */ + readonly onRequestMarketplace?: () => void; +} + +type MarketState = + | { readonly status: 'idle' } + | { readonly status: 'loading' } + | { readonly status: 'error'; readonly message: string } + | { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string }; + +const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [ + { id: 'installed', label: 'Installed' }, + { id: 'official', label: 'Official' }, + { id: 'third-party', label: 'Third-party' }, + { id: 'custom', label: 'Custom' }, +]; + +export class PluginsPanelComponent extends Container implements Focusable { + focused = false; + + private readonly opts: PluginsPanelOptions; + private readonly customInput = new Input(); + private activeTabIndex: number; + private selectedIndex = 0; + private market: MarketState = { status: 'idle' }; + private installing: string | undefined; + + constructor(opts: PluginsPanelOptions) { + super(); + this.opts = opts; + this.activeTabIndex = Math.max( + 0, + PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')), + ); + if (opts.selectedId !== undefined && this.activeTab.id === 'installed') { + const idx = opts.installed.findIndex((p) => p.id === opts.selectedId); + if (idx >= 0) this.selectedIndex = idx; + } + this.customInput.onSubmit = (value) => { + const source = value.trim(); + if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source }); + }; + } + + marketplaceStatus(): MarketState['status'] { + return this.market.status; + } + + setMarketplaceLoading(): void { + this.market = { status: 'loading' }; + } + + setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void { + this.market = { status: 'loaded', entries, source }; + } + + setMarketplaceError(message: string): void { + 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]!; + } + + private get marketplaceEntries(): readonly PluginMarketplaceEntry[] { + if (this.market.status !== 'loaded') return []; + const { installedIds } = this.opts; + return this.market.entries.toSorted( + (a, b) => Number(installedIds.has(b.id)) - Number(installedIds.has(a.id)), + ); + } + + private get installedVersions(): ReadonlyMap<string, string | undefined> { + return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version])); + } + + private get officialEntries(): readonly PluginMarketplaceEntry[] { + // The hardcoded Web Bridge entry always leads the Official tab, even when + // the catalog is loading or unreachable. Dedupe by id so a catalog that + // also lists it does not render a second row. + return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + // Dedupe by id (not reference): if the official catalog also lists + // kimi-webbridge, the pinned row already represents it, so suppress the + // catalog copy to avoid a duplicate row on the Official tab. + return this.marketplaceEntries.filter( + (entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id, + ); + } + + private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { + // Anything not explicitly marked official lands here: `curated` entries plus + // entries that omit `tier` (custom marketplaces often do). Without this, + // untiered entries would be invisible in both marketplace tabs. + return this.marketplaceEntries.filter((entry) => entry.tier !== 'official'); + } + + private requestMarketplaceIfNeeded(): void { + // 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?.(); + } + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.tab)) { + this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeTabIndex = + (this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + switch (this.activeTab.id) { + case 'installed': + this.handleInstalledInput(data); + return; + case 'official': + case 'third-party': + this.handleMarketplaceInput(data); + return; + case 'custom': + this.customInput.handleInput(data); + return; + } + } + + private handleInstalledInput(data: string): void { + const plugins = this.opts.installed; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1); + return; + } + const plugin = plugins[this.selectedIndex]; + const ch = printableChar(data); + // Decode Space for terminals that send printable keys via Kitty/CSI-u + // sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)` + // alone misses those and the toggle silently stops working. + if (matchesKey(data, Key.space) || ch === ' ') { + if (plugin !== undefined) { + this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled }); + } + return; + } + if (ch === 'd' || ch === 'D') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id }); + return; + } + if (ch === 'm' || ch === 'M') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id }); + return; + } + if (ch === 'r' || ch === 'R') { + this.opts.onSelect({ kind: 'reload' }); + 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 }); + } + } + + private handleMarketplaceInput(data: string): void { + const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + // Clamp to 0 while the catalog is still loading (entries empty); otherwise + // `entries.length - 1` is -1 and a later Enter reads `entries[-1]`. + this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter)) { + const entry = entries[this.selectedIndex]; + if (entry === undefined) return; + if (isPinnedWebBridgeEntry(entry)) { + this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); + return; + } + this.opts.onSelect({ kind: 'install', entry }); + } + } + + override invalidate(): void { + super.invalidate(); + this.customInput.invalidate(); + } + + 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' + ? this.installedHint() + : tab === 'custom' + ? ' Tab switch · Enter install · Esc cancel' + : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + mutedHintLine(hint, colors), + '', + renderTabStrip({ + labels: PLUGINS_PANEL_TABS.map((t) => t.label), + activeIndex: this.activeTabIndex, + width, + colors, + }), + '', + ]; + + if (tab === 'installed') this.renderInstalled(lines, width); + else if (tab === 'official') this.renderOfficial(lines, width); + else if (tab === 'third-party') this.renderThirdParty(lines, width); + else this.renderCustom(lines, width); + + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderInstalled(lines: string[], width: number): void { + const { installed } = this.opts; + const colors = currentTheme.palette; + if (installed.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + } else { + for (let i = 0; i < installed.length; i++) { + lines.push(...this.renderInstalledRow(installed[i]!, i, width)); + } + } + lines.push(''); + 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; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const 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); + } + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderMarketplaceTab( + lines: string[], + width: number, + entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, + ): void { + const colors = currentTheme.palette; + if (this.market.status === 'loading' || this.market.status === 'idle') { + lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…')); + return; + } + if (this.market.status === 'error') { + lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`)); + lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors)); + return; + } + if (entries.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins found.')); + } else { + for (let i = 0; i < entries.length; i++) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); + } + } + const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; + lines.push(''); + lines.push( + mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors), + ); + lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); + } + + private renderOfficial(lines: string[], width: number): void { + // Web Bridge is pinned above the catalog and stays visible while the + // catalog loads or errors, since it's built into the TUI rather than + // fetched. Catalog rows shift down by one index to match. + lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); + this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1); + } + + private renderThirdParty(lines: string[], width: number): void { + this.renderMarketplaceTab(lines, width, this.thirdPartyEntries); + } + + private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const status = isPinnedWebBridgeEntry(entry) + ? 'open in browser' + : marketplaceEntryStatus(entry, this.installedVersions); + const line = + prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderCustom(lines: string[], width: number): void { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors)); + 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[] { @@ -543,8 +758,8 @@ function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { function mcpServerDescription(server: PluginMcpServerInfo): string { const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable'; - if (server.transport === 'http') { - return `${action} · HTTP · ${server.url ?? server.runtimeName}`; + if (server.transport === 'http' || server.transport === 'sse') { + return `${action} · ${server.transport.toUpperCase()} · ${server.url ?? server.runtimeName}`; } const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : ''; const command = `${server.command ?? ''}${args}`.trim(); @@ -579,6 +794,21 @@ function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } +function marketplaceEntryStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap<string, string | undefined>, +): string { + const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return installStatus(entry); + } +} + function sectionLabel(label: string, colors: ColorPalette): string { return chalk.hex(colors.textDim).bold(` ${label}`); } @@ -595,8 +825,11 @@ function statusStyle( return chalk.hex(colors.warning); } -function mutedHintLine(text: string, colors: ColorPalette): string { - return chalk.hex(colors.textMuted)(text); +function mutedHintLine(text: string, colors?: ColorPalette): string { + if (colors !== undefined) { + return chalk.hex(colors.textMuted)(text); + } + return currentTheme.fg('textMuted', text); } function wrapOverviewDescription(text: string, width: number): string[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 2cc7fcccf..7ae0da03d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -42,12 +42,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -61,7 +60,6 @@ export interface ProviderManagerOptions { readonly providers: Record<string, ProviderConfig>; /** Provider id of the currently active model. */ readonly activeProviderId?: string; - readonly colors: ColorPalette; readonly onAdd: () => void; /** Delete all providers under a source (Open Platform / custom-registry * fetch / standalone). Passed the full provider-id list so the host @@ -355,27 +353,26 @@ export class ProviderManagerComponent extends Container implements Focusable { } override render(width: number): string[] { - const { colors } = this.opts; const lines: string[] = []; // Header shape mirrors the model dialog (see model-selector.ts): a single // top border, the title, the keymap hint, then a blank line. No inner // border under the title. - const border = chalk.hex(colors.primary)('─'.repeat(width)); + const border = currentTheme.fg('primary', '─'.repeat(width)); lines.push(border); - lines.push(chalk.hex(colors.primary).bold(' Providers')); - lines.push(chalk.hex(colors.textMuted)(' ' + HEADER_HINT)); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); lines.push(''); const rows = this.rows; if (rows.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' No providers configured.')); + lines.push(currentTheme.fg('textMuted', ' No providers configured.')); } else { const view = this.page(); for (let i = view.start; i < view.end; i++) { const row = rows[i]; if (row === undefined) continue; - for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) { + for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width })) { lines.push(line); } } @@ -389,7 +386,8 @@ export class ProviderManagerComponent extends Container implements Focusable { const view = this.page(); if (view.pageCount > 1) { lines.push( - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', ` Page ${String(view.page + 1)}/${String(view.pageCount)}`, ), ); @@ -401,10 +399,9 @@ export class ProviderManagerComponent extends Container implements Focusable { } private renderConfirmLine(width: number): string { - const { colors } = this.opts; const confirm = this.confirm; const prompt = confirm?.label ?? ''; - const styled = chalk.hex(colors.warning).bold(` ${prompt} [y/N]`); + const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); return truncateToWidth(styled, width, '…'); } } @@ -412,19 +409,21 @@ export class ProviderManagerComponent extends Container implements Focusable { function renderRow( row: Row, - ctx: { isSelected: boolean; width: number; colors: ColorPalette }, + ctx: { isSelected: boolean; width: number }, ): string[] { - const { isSelected, width, colors } = ctx; + const { isSelected, width } = ctx; const pointer = isSelected ? SELECT_POINTER : ' '; - const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim); + const pointerStyle = (text: string) => + isSelected ? currentTheme.fg('primary', text) : currentTheme.fg('textDim', text); // The synthetic "Add New Platform" row is an action/CTA: keep it in the brand // color so it never reads as disabled, and bold it when selected (matching // the other rows' selected treatment). - const labelStyle = isSelected - ? chalk.hex(colors.primary).bold - : row.kind === 'add' - ? chalk.hex(colors.primary) - : chalk.hex(colors.text); + const labelStyle = (text: string) => + isSelected + ? currentTheme.boldFg('primary', text) + : row.kind === 'add' + ? currentTheme.fg('primary', text) + : currentTheme.fg('text', text); // The active provider is flagged with a trailing "← current" (success), // matching the model selector's current-item marker — see .agents/skills/write-tui/DESIGN.md. @@ -435,13 +434,13 @@ function renderRow( const labelWidth = Math.max(0, width - 4 - visibleWidth(marker)); const labelText = truncateToWidth(row.label, labelWidth, '…'); let line = ` ${pointerStyle(`${pointer} `)}${labelStyle(labelText)}`; - if (isActive) line += chalk.hex(colors.success)(marker); + if (isActive) line += currentTheme.fg('success', marker); const lines: string[] = [line]; if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) { const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…'); - lines.push(chalk.hex(colors.textMuted)(` ${urlText}`)); + lines.push(currentTheme.fg('textMuted', ` ${urlText}`)); } return lines; diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 2a9cac262..6764b88d8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -15,15 +15,14 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, } from '#/tui/reverse-rpc/types'; -import type { ColorPalette } from '#/tui/theme/colors'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; @@ -74,7 +73,6 @@ export class QuestionDialogComponent extends Container implements Focusable { focused = false; private readonly request: PendingQuestion; - private readonly colors: ColorPalette; private readonly onAnswer: (response: QuestionPanelResponse) => void; private readonly maxVisibleOptions: number; private readonly otherInput = new Input(); @@ -103,14 +101,12 @@ export class QuestionDialogComponent extends Container implements Focusable { constructor( request: PendingQuestion, onAnswer: (response: QuestionPanelResponse) => void, - colors: ColorPalette, maxVisibleOptions = 6, onToggleToolOutput?: () => void, ) { super(); this.request = request; this.onAnswer = onAnswer; - this.colors = colors; this.maxVisibleOptions = maxVisibleOptions; this.onToggleToolOutput = onToggleToolOutput; this.otherInput.onSubmit = (value) => { @@ -445,13 +441,12 @@ export class QuestionDialogComponent extends Container implements Focusable { const question = this.request.data.questions[questionIdx]; if (question === undefined) return []; - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const success = chalk.hex(colors.success); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const success = (text: string) => currentTheme.fg('success', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); @@ -501,13 +496,13 @@ export class QuestionDialogComponent extends Container implements Focusable { if (question.multi_select) { const checked = isSelected ? '✓' : ' '; prefix = ` [${checked}] `; - if (isSelected && isCursor) tone = (s) => success.bold(s); + if (isSelected && isCursor) tone = (s) => currentTheme.boldFg('success', s); else if (isSelected) tone = success; else if (isCursor) tone = accent; else tone = dim; } else if (isSelected && this.isAnswered(questionIdx)) { prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `; - tone = isCursor ? (s) => success.bold(s) : success; + tone = isCursor ? (s) => currentTheme.boldFg('success', s) : success; } else if (isCursor) { prefix = ` → [${String(num)}] `; tone = accent; @@ -543,17 +538,16 @@ export class QuestionDialogComponent extends Container implements Focusable { } private renderSubmitTab(width: number): string[] { - const colors = this.colors; - const accent = chalk.hex(colors.primary); - const dim = chalk.hex(colors.textDim); - const text = chalk.hex(colors.text); - const warning = chalk.hex(colors.warning); + const accent = (text: string) => currentTheme.fg('primary', text); + const dim = (text: string) => currentTheme.fg('textDim', text); + const text = (t: string) => currentTheme.fg('text', t); + const warning = (text: string) => currentTheme.fg('warning', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); lines.push(''); - lines.push(text.bold(` ${REVIEW_TITLE}`)); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); const reviewWarning = this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); if (reviewWarning !== undefined) { @@ -608,8 +602,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } private pushTabs(lines: string[]): void { - const dim = chalk.hex(this.colors.textDim); - const active = chalk.bgHex(this.colors.primary).hex(this.colors.text).bold; + const dim = (text: string) => currentTheme.fg('textDim', text); + const active = (text: string) => + currentTheme.bg('primary', currentTheme.boldFg('text', text)); const tabs: string[] = []; for (let i = 0; i < this.request.data.questions.length; i++) { @@ -620,7 +615,7 @@ export class QuestionDialogComponent extends Container implements Focusable { ? question.header : `Q${String(i + 1)}`; if (i === this.currentTab) tabs.push(active(` ${label} `)); - else if (this.isAnswered(i)) tabs.push(chalk.hex(this.colors.success)(`(✓) ${label}`)); + else if (this.isAnswered(i)) tabs.push(currentTheme.fg('success', `(✓) ${label}`)); else tabs.push(dim(`(○) ${label}`)); } @@ -750,14 +745,14 @@ export class QuestionDialogComponent extends Container implements Focusable { const checked = isSelected ? '✓' : ' '; const body = ` [${checked}] ${option.label}: `; prefix = isSelected - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } else { const body = ` → [${String(num)}] ${option.label}: `; prefix = isSelected && this.isAnswered(questionIdx) - ? chalk.hex(this.colors.success).bold(body) - : chalk.hex(this.colors.primary)(body); + ? currentTheme.boldFg('success', body) + : currentTheme.fg('primary', body); } const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index b39ced4a3..c8bd9017b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -9,12 +9,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +} from '@moonshot-ai/pi-tui'; import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; export interface SessionRow { readonly id: string; @@ -75,37 +74,59 @@ function singleLine(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } +function sessionSearchText(session: SessionRow): string { + return singleLine((session.title ?? session.id).trim() || session.id); +} + export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private colors: ColorPalette; - private onSelect: (sessionId: string) => void; + private onSelect: (session: SessionRow) => void; private onCancel: () => void; + private onToggleScope?: (selectedSessionId: string) => void; private maxVisibleSessions: number; + private pageSize: number; + private visibleCount: number; + private scope: 'cwd' | 'all'; private loading: boolean; + private list: SearchableList<SessionRow>; focused = false; - private selectedIndex = 0; constructor(opts: { sessions: SessionRow[]; loading: boolean; currentSessionId: string; - colors: ColorPalette; - onSelect: (sessionId: string) => void; + scope?: 'cwd' | 'all'; + initialSelectedSessionId?: string; + pageSize?: number; + onSelect: (session: SessionRow) => void; onCancel: () => void; onCtrlC?: () => void; onCtrlD?: () => void; + onToggleScope?: (selectedSessionId: string) => void; maxVisibleSessions?: number; }) { super(); this.sessions = opts.sessions; this.loading = opts.loading; this.currentSessionId = opts.currentSessionId; - this.colors = opts.colors; + this.scope = opts.scope ?? 'cwd'; this.onSelect = opts.onSelect; this.onCancel = opts.onCancel; + this.onToggleScope = opts.onToggleScope; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; + this.pageSize = Math.max(1, opts.pageSize ?? 50); + const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); + this.list = new SearchableList({ + items: this.sessions, + toSearchText: sessionSearchText, + pageSize: this.pageSize, + initialIndex, + searchable: true, + }); + const initialLoadedPages = Math.ceil((initialIndex + 1) / this.pageSize); + this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize); this.onCtrlC = opts.onCtrlC; this.onCtrlD = opts.onCtrlD; } @@ -113,6 +134,33 @@ export class SessionPickerComponent extends Container implements Focusable { private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; + private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { + if (initialSelectedSessionId === undefined) return 0; + const index = this.sessions.findIndex((session) => session.id === initialSelectedSessionId); + return Math.max(index, 0); + } + + private filteredSessions(): readonly SessionRow[] { + return this.list.view().items; + } + + private loadedSessions(sessions: readonly SessionRow[] = this.filteredSessions()): SessionRow[] { + return sessions.slice(0, Math.min(sessions.length, this.visibleCount)); + } + + private syncVisibleCount(previousQuery: string): void { + const view = this.list.view(); + if (view.query !== previousQuery) { + this.visibleCount = Math.min(view.items.length, this.pageSize); + return; + } + + const loadedCount = Math.min(view.items.length, this.visibleCount); + if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { + this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); + } + } + handleInput(data: string): void { if (matchesKey(data, Key.ctrl('c'))) { this.onCtrlC?.(); @@ -122,21 +170,27 @@ export class SessionPickerComponent extends Container implements Focusable { this.onCtrlD?.(); return; } + if (matchesKey(data, Key.ctrl('a'))) { + this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); + return; + } if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) { + this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); + return; + } this.onCancel(); return; } - if (matchesKey(data, Key.enter) && this.sessions.length > 0) { - const session = this.sessions[this.selectedIndex]; - if (session) this.onSelect(session.id); + if (matchesKey(data, Key.enter)) { + const session = this.list.selected(); + if (session) this.onSelect(session); return; } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.sessions.length - 1, this.selectedIndex + 1); + + const previousQuery = this.list.view().query; + if (this.list.handleKey(data)) { + this.syncVisibleCount(previousQuery); } } @@ -151,67 +205,101 @@ export class SessionPickerComponent extends Container implements Focusable { // the clamp in `render()` is what guarantees the renderer's invariant and // prevents the "Rendered line exceeds terminal width" crash (issue #240). private renderLines(width: number): string[] { - const colors = this.colors; - const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))]; + const title = this.scope === 'all' ? 'All sessions' : 'Sessions'; + const scopeHint = + this.onToggleScope === undefined + ? undefined + : this.scope === 'all' + ? 'Ctrl+A current cwd' + : 'Ctrl+A all'; if (this.loading) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); lines.push( - chalk.hex(colors.textMuted)(truncateToWidth('Loading sessions...', width, ELLIPSIS)), + currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } if (this.sessions.length === 0) { - lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); - lines.push( - chalk.hex(colors.textMuted)( - truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS), - ), + const hintParts = [scopeHint, 'Esc cancel'].filter( + (item): item is string => item !== undefined, ); - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), + ); + lines.push(''); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } - const headerLabel = 'Sessions '; - const headerHint = '↑↓ navigate · Enter select · Esc cancel'; - const labelWidth = visibleWidth(headerLabel); - const hintBudget = Math.max(0, width - labelWidth); - const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); - lines.push( - chalk.hex(colors.primary).bold(headerLabel) + chalk.hex(colors.textMuted)(shownHint), - ); + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintParts = [ + ...(view.query.length > 0 ? ['Backspace clear'] : []), + '↑↓ navigate', + scopeHint, + 'Enter select', + 'Esc cancel', + ].filter((item): item is string => item !== undefined); + + lines.push(currentTheme.boldFg('primary', title) + titleSuffix); + lines.push(currentTheme.fg('textMuted', hintParts.join(' · '))); lines.push(''); + if (view.query.length > 0) { + lines.push(currentTheme.fg('primary', 'Search: ') + currentTheme.fg('text', view.query)); + } + + const loadedSessions = this.loadedSessions(view.items); + if (loadedSessions.length === 0) { + lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines; + } + const selectedIndex = view.selectedIndex; const visibleStart = Math.max( 0, Math.min( - this.selectedIndex - Math.floor(this.maxVisibleSessions / 2), - Math.max(0, this.sessions.length - this.maxVisibleSessions), + selectedIndex - Math.floor(this.maxVisibleSessions / 2), + Math.max(0, loadedSessions.length - this.maxVisibleSessions), ), ); - const visibleSessions = this.sessions.slice( + const visibleSessions = loadedSessions.slice( visibleStart, visibleStart + this.maxVisibleSessions, ); for (const [vi, session] of visibleSessions.entries()) { const index = visibleStart + vi; - const isSelected = index === this.selectedIndex; + const isSelected = index === selectedIndex; const isCurrent = session.id === this.currentSessionId; const card = this.renderSessionCard(width, session, isSelected, isCurrent); lines.push(...card); if (vi < visibleSessions.length - 1) lines.push(''); } - if (this.sessions.length > visibleSessions.length) { + const filteredCount = view.items.length; + if (loadedSessions.length > visibleSessions.length || view.query.length > 0) { lines.push(''); - const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`; - lines.push(chalk.hex(colors.textMuted)(truncateToWidth(footer, width, ELLIPSIS))); + const totalSuffix = + view.query.length > 0 + ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`; + lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } - lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -221,12 +309,12 @@ export class SessionPickerComponent extends Container implements Focusable { isSelected: boolean, isCurrent: boolean, ): string[] { - const colors = this.colors; const pointer = isSelected ? SELECT_POINTER : ' '; const indent = ' '; const indentWidth = visibleWidth(indent); - const titleColor = isSelected ? colors.primary : colors.text; - const titleStyle = isSelected ? chalk.hex(titleColor).bold : chalk.hex(titleColor); + const titleColor: 'primary' | 'text' = isSelected ? 'primary' : 'text'; + const titleStyle = (text: string) => + isSelected ? currentTheme.boldFg(titleColor, text) : currentTheme.fg(titleColor, text); const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; @@ -241,10 +329,10 @@ export class SessionPickerComponent extends Container implements Focusable { const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); - let header = chalk.hex(isSelected ? colors.primary : colors.textDim)(pointer + ' '); + let header = currentTheme.fg(isSelected ? 'primary' : 'textDim', pointer + ' '); header += titleStyle(shownTitle); - if (time.length > 0) header += ' ' + chalk.hex(colors.textDim)(time); - if (badge.length > 0) header += ' ' + chalk.hex(colors.success)(badge); + if (time.length > 0) header += ' ' + currentTheme.fg('textDim', time); + if (badge.length > 0) header += ' ' + currentTheme.fg('success', badge); const card: string[] = [header]; // Session id is rendered in full at normal widths (the final clamp in @@ -261,22 +349,23 @@ export class SessionPickerComponent extends Container implements Focusable { if (idLineWidth + metaGapWidth + dirWidth <= width) { card.push( indent + - chalk.hex(colors.textMuted)(fullId) + - chalk.hex(colors.textDim)(metaGap) + - chalk.hex(colors.textMuted)(aliasedDir), + currentTheme.fg('textMuted', fullId) + + currentTheme.fg('textDim', metaGap) + + currentTheme.fg('textMuted', aliasedDir), ); } else { // Not enough room for both on one line — keep the id intact and put the // directory on the next line (left-truncated only if it still doesn't fit). card.push( indent + - chalk.hex(colors.textMuted)( + currentTheme.fg( + 'textMuted', truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), ), ); const dirBudget = Math.max(8, width - indentWidth); const dir = truncatePathLeft(aliasedDir, dirBudget); - card.push(indent + chalk.hex(colors.textMuted)(dir)); + card.push(indent + currentTheme.fg('textMuted', dir)); } const rawPrompt = session.last_prompt?.trim(); @@ -285,7 +374,7 @@ export class SessionPickerComponent extends Container implements Focusable { const promptMarkerWidth = visibleWidth(promptMarker); const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); - const promptLine = indent + chalk.hex(colors.textDim)(promptMarker + promptText); + const promptLine = indent + currentTheme.fg('textDim', promptMarker + promptText); card.push(promptLine); } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 3e6e42691..81e4b8d12 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - export type SettingsSelection = | 'model' | 'theme' @@ -62,7 +60,6 @@ function isSettingsSelection(value: string): value is SettingsSelection { } export interface SettingsSelectorOptions { - readonly colors: ColorPalette; readonly onSelect: (value: SettingsSelection) => void; readonly onCancel: () => void; } @@ -72,7 +69,6 @@ export class SettingsSelectorComponent extends ChoicePickerComponent { super({ title: 'Settings', options: [...SETTINGS_OPTIONS], - colors: opts.colors, onSelect: (value) => { if (isSettingsSelection(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 875905a61..341ced723 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -5,11 +5,10 @@ import { visibleWidth, type Component, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -22,7 +21,6 @@ export interface StartPermissionOption<TChoice extends StartPermissionChoice = S export interface StartPermissionPromptOptions< TChoice extends StartPermissionChoice = StartPermissionChoice, > { - readonly colors: ColorPalette; readonly title: string; readonly noticeLines: readonly string[]; readonly options: readonly StartPermissionOption<TChoice>[]; @@ -59,19 +57,18 @@ export class StartPermissionPromptComponent<TChoice extends StartPermissionChoic } render(width: number): string[] { - const { colors } = this.opts; - const rule = chalk.hex(colors.primary)('─'.repeat(width)); + const rule = currentTheme.fg('primary', '─'.repeat(width)); const lines = [ rule, - chalk.hex(colors.primary).bold(` ${this.opts.title}`), - chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc cancel'), + currentTheme.boldFg('primary', ` ${this.opts.title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), '', ]; const textWidth = Math.max(20, width - 2); for (const paragraph of this.opts.noticeLines) { for (const line of wrapPlain(paragraph, textWidth)) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + lines.push(` ${styleModeNames(line, 'textMuted')}`); } lines.push(''); } @@ -81,11 +78,11 @@ export class StartPermissionPromptComponent<TChoice extends StartPermissionChoic const selected = i === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; lines.push( - chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `) + - styleLabel(option.label, selected, colors), + currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + + styleLabel(option.label, selected), ); for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + lines.push(` ${styleModeNames(line, 'textMuted')}`); } lines.push(''); } @@ -95,19 +92,17 @@ export class StartPermissionPromptComponent<TChoice extends StartPermissionChoic } } -function styleLabel(label: string, selected: boolean, colors: ColorPalette): string { - if (selected) return chalk.hex(colors.primary).bold(label); - return styleModeNames(label, colors, colors.text); +function styleLabel(label: string, selected: boolean): string { + if (selected) return currentTheme.boldFg('primary', label); + return styleModeNames(label, 'text'); } -function styleModeNames(text: string, colors: ColorPalette, baseHex: string): string { - const base = chalk.hex(baseHex); - const strong = chalk.hex(colors.textStrong).bold; +function styleModeNames(text: string, baseToken: 'text' | 'textMuted'): string { return text .split(/(\b(?:Manual|Auto|YOLO)\b)/g) .map((part) => { - if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return strong(part); - return base(part); + if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return currentTheme.boldFg('textStrong', part); + return currentTheme.fg(baseToken, part); }) .join(''); } diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts index cbb910b04..694c0c0e6 100644 --- a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -1,14 +1,11 @@ -import type { ColorPalette } from '#/tui/theme/colors'; - import { StartPermissionPromptComponent, type StartPermissionOption, } from './start-permission-prompt'; -export type SwarmStartPermissionChoice = 'auto' | 'manual'; +export type SwarmStartPermissionChoice = 'auto' | 'yolo' | 'manual'; export interface SwarmStartPermissionPromptOptions { - readonly colors: ColorPalette; readonly onSelect: (choice: SwarmStartPermissionChoice) => void; readonly onCancel: () => void; } @@ -20,6 +17,12 @@ const OPTIONS: readonly StartPermissionOption<SwarmStartPermissionChoice>[] = [ description: 'Best for swarm tasks. Tools are approved automatically, and questions are skipped.', }, + { + value: 'yolo', + label: 'Switch to YOLO and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, { value: 'manual', label: 'Start in Manual', @@ -37,7 +40,6 @@ const NOTICE_LINES = [ export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent<SwarmStartPermissionChoice> { constructor(opts: SwarmStartPermissionPromptOptions) { super({ - colors: opts.colors, title: 'Start a swarm task with approvals on?', noticeLines: NOTICE_LINES, options: OPTIONS, diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 62cd2afec..9a986b096 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -19,12 +19,11 @@ import { Key, matchesKey, truncateToWidth, - visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, @@ -40,12 +39,14 @@ export interface TabbedModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; - readonly colors: ColorPalette; + readonly currentThinkingEffort: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -102,7 +103,12 @@ export class TabbedModelSelectorComponent extends Container implements Focusable // Layout: divider, title, hint, blank, tab strip, blank, then the model // list. The inner selector's blank line (inner[3]) separates the hint from // the tab strip; an extra blank separates the tabs from their list. - const stripLine = this.renderTabStrip(width); + const stripLine = renderTabStrip({ + labels: this.tabs.map((tab) => tab.label), + activeIndex: this.activeIndex, + width, + colors: currentTheme.palette, + }); const out: string[] = [ inner[0] ?? '', inner[1] ?? '', @@ -128,83 +134,6 @@ export class TabbedModelSelectorComponent extends Container implements Focusable tab.selector.focused = this.focused && i === this.activeIndex; } } - - /** Style a tab segment. The active tab is filled with the brand background - * (matching the AskUserQuestion dialog); inactive tabs are muted. Both have - * the same visible width so switching never shifts the layout. */ - private styleTab(label: string, isActive: boolean): string { - const { colors } = this.opts; - const cell = ` ${label} `; - return isActive - ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) - : chalk.hex(colors.textMuted)(cell); - } - - private renderTabStrip(width: number): string { - const { colors } = this.opts; - const segments: string[] = []; - for (let i = 0; i < this.tabs.length; i++) { - const tab = this.tabs[i]!; - segments.push(this.styleTab(tab.label, i === this.activeIndex)); - } - - // If everything fits with a leading space, show the whole strip. The - // provider-switch hint lives in the inner selector's hint line, not here. - const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); - if (1 + totalSegmentWidth <= width) { - return ' ' + segments.join(' '); - } - - // Scrolling needed. Find the widest window that contains activeIndex. - const segmentWidths = segments.map((s) => visibleWidth(s)); - let start = this.activeIndex; - let end = this.activeIndex + 1; - let contentWidth = segmentWidths[this.activeIndex]!; - - const fits = (s: number, e: number, cw: number): boolean => { - const needLeft = s > 0; - const needRight = e < segments.length; - const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); - return cw + frameWidth <= width; - }; - - while (true) { - const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; - const rightW = end < segments.length ? segmentWidths[end]! : Infinity; - if (leftW === Infinity && rightW === Infinity) break; - - if (leftW <= rightW) { - if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else { - break; - } - } else { - if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else { - break; - } - } - } - - const hasLeft = start > 0; - const hasRight = end < segments.length; - let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; - strip += segments.slice(start, end).join(' '); - if (hasRight) { - strip += chalk.hex(colors.textMuted)(' >'); - } - return strip; - } } function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { @@ -250,11 +179,11 @@ function makeSelector( models: subset, currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), - currentThinking: opts.currentThinking, - colors: opts.colors, + currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, }; return new ModelSelectorComponent(inner); diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index b9a525ff5..c0f647f67 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -17,11 +17,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -30,7 +29,6 @@ export interface TaskOutputViewerProps { readonly taskId: string; readonly info: BackgroundTaskInfo | undefined; readonly output: string; - readonly colors: ColorPalette; readonly onClose: () => void; } @@ -43,17 +41,17 @@ const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { lost: 'lost', }; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -127,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; } @@ -183,18 +190,17 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' Task output '); - const id = chalk.hex(colors.text).bold(this.props.taskId); + const title = currentTheme.boldFg('primary', ' Task output '); + const id = currentTheme.boldFg('text', this.props.taskId); const info = this.props.info; const segments: string[] = []; if (info !== undefined) { - segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); if (info.kind === 'process' && info.exitCode !== null) { - segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); + segments.push(currentTheme.fg('textMuted', `exit ${String(info.exitCode)}`)); } if (info.description && info.description.length > 0) { - segments.push(chalk.hex(colors.textMuted)(info.description)); + segments.push(currentTheme.fg('textMuted', info.description)); } } const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); @@ -202,9 +208,6 @@ export class TaskOutputViewer extends Container implements Focusable { } private renderBody(width: number, bodyHeight: number): string[] { - const colors = this.props.colors; - const stroke = colors.primary; - // Reserve 1 col for left/right border each, 1 col for left padding. const innerWidth = Math.max(1, width - 4); @@ -214,24 +217,23 @@ export class TaskOutputViewer extends Container implements Focusable { if (this.scrollTop < 0) this.scrollTop = 0; const viewRows = bodyHeight - 2; // inside top + bottom border - const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); const out: string[] = [top]; for (let i = 0; i < viewRows; i++) { const lineIndex = this.scrollTop + i; const raw = this.lines[lineIndex] ?? ''; - const inner = fitExactly(chalk.hex(colors.text)(raw), innerWidth); - out.push(chalk.hex(stroke)('│ ') + inner + chalk.hex(stroke)(' │')); + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); } out.push(bottom); return out; } private renderFooter(width: number, bodyHeight: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); const total = this.lines.length; const viewRows = Math.max(1, bodyHeight - 2); @@ -241,12 +243,13 @@ export class TaskOutputViewer extends Container implements Focusable { const lineFrom = this.scrollTop + 1; const lineTo = Math.min(total, this.scrollTop + viewRows); - const position = chalk.hex(colors.textMuted)( + const position = currentTheme.fg( + 'textMuted', ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 44c9f192b..7902e81e1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -21,12 +21,11 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; -import type { ColorPalette } from '@/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; const ELLIPSIS = '…'; @@ -40,7 +39,6 @@ export interface TasksBrowserProps { readonly tailOutput: string | undefined; readonly tailLoading: boolean; readonly flashMessage: string | undefined; - readonly colors: ColorPalette; readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; readonly onRefresh: () => void; @@ -74,17 +72,17 @@ const LIST_COL_MIN = 28; const LIST_COL_MAX = 44; const LIST_COL_RATIO = 0.32; -function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { +function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': - return colors.success; + return 'success'; case 'completed': - return colors.textMuted; + return 'textMuted'; case 'failed': case 'timed_out': case 'killed': case 'lost': - return colors.error; + return 'error'; } } @@ -132,8 +130,13 @@ function visibleTasks( tasks: readonly BackgroundTaskInfo[], filter: TasksFilter, ): BackgroundTaskInfo[] { - if (filter === 'all') return [...tasks]; - return tasks.filter((t) => !isTerminal(t.status)); + // The /tasks panel is for background task management. Foreground tasks + // (detached === false) are shown in the main transcript instead, and only + // appear here after being detached via Ctrl+B. `detached !== false` keeps + // reconcile ghosts whose `detached` field may be undefined. + const backgroundOnly = tasks.filter((t) => t.detached !== false); + if (filter === 'all') return [...backgroundOnly]; + return backgroundOnly.filter((t) => !isTerminal(t.status)); } function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { @@ -330,36 +333,39 @@ export class TasksBrowserApp extends Container implements Focusable { // ── header / footer ────────────────────────────────────────────────── private renderHeader(width: number): string { - const colors = this.props.colors; - const title = chalk.hex(colors.primary).bold(' TASK BROWSER '); - const filterText = chalk.hex(colors.textMuted)( + const title = currentTheme.boldFg('primary', ' TASK BROWSER '); + const filterText = currentTheme.fg( + 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); - const counts = countByStatus(this.props.tasks); + // Count only the tasks actually listed (background tasks after the + // foreground-task filter), so a foreground-only session doesn't read + // "1 running / 1 total" above an empty list. + const visible = visibleTasks(this.props.tasks, this.props.filter); + const counts = countByStatus(visible); const countSegments: string[] = []; if (counts.running > 0) - countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); + countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); if (counts.completed > 0) - countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); + countSegments.push(currentTheme.fg('textDim', ` ${String(counts.completed)} completed `)); if (counts.terminalFailed > 0) countSegments.push( - chalk.hex(colors.error)(` ${String(counts.terminalFailed)} interrupted `), + currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); } private renderFooter(width: number): string { - const colors = this.props.colors; - const key = (text: string): string => chalk.hex(colors.primary).bold(text); - const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); if (this.pendingStopTaskId !== undefined) { - const warn = (text: string): string => chalk.hex(colors.warning).bold(text); + const warn = (text: string): string => currentTheme.boldFg('warning', text); const line = - ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + + ` ${warn('Stop')} ${currentTheme.fg('text', this.pendingStopTaskId)}? ` + `${key('Y')} ${dim('confirm')} ${key('N')}${dim('/')}${key('esc')} ${dim('cancel')} `; return fitExactly(line, width); } @@ -375,7 +381,7 @@ export class TasksBrowserApp extends Container implements Focusable { const left = parts.join(' '); const flash = this.props.flashMessage; if (flash !== undefined && flash.length > 0) { - const flashStyled = chalk.hex(colors.warning)(` ${flash} `); + const flashStyled = currentTheme.fg('warning', ` ${flash} `); const total = visibleWidth(left) + visibleWidth(flashStyled); if (total <= width) { return left + ' '.repeat(width - total) + flashStyled; @@ -402,29 +408,28 @@ export class TasksBrowserApp extends Container implements Focusable { for (let i = 0; i < height; i++) out.push(' '.repeat(width)); return out; } - const stroke = this.props.colors.primary; const innerWidth = width - 2; const innerHeight = height - 2; - const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title); + const titleStyled = currentTheme.boldFg('textStrong', title); const titleWidth = visibleWidth(titleStyled); const titleSegment = `─ ${titleStyled} `; const titleSegmentWidth = visibleWidth(titleSegment); const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth - ? chalk.hex(stroke)('─ ') + + ? currentTheme.fg('primary', '─ ') + titleStyled + ' ' + - chalk.hex(stroke)('─'.repeat(remainingDashes)) - : chalk.hex(stroke)('─'.repeat(innerWidth)); - const top = chalk.hex(stroke)('┌') + topMid + chalk.hex(stroke)('┐'); - const bottom = chalk.hex(stroke)('└' + '─'.repeat(innerWidth) + '┘'); + currentTheme.fg('primary', '─'.repeat(remainingDashes)) + : currentTheme.fg('primary', '─'.repeat(innerWidth)); + const top = currentTheme.fg('primary', '┌') + topMid + currentTheme.fg('primary', '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(innerWidth) + '┘'); const lines: string[] = [top]; for (let i = 0; i < innerHeight; i++) { const inner = content[i] ?? ''; - lines.push(chalk.hex(stroke)('│') + fitExactly(inner, innerWidth) + chalk.hex(stroke)('│')); + lines.push(currentTheme.fg('primary', '│') + fitExactly(inner, innerWidth) + currentTheme.fg('primary', '│')); } lines.push(bottom); return lines; @@ -441,7 +446,7 @@ export class TasksBrowserApp extends Container implements Focusable { this.props.filter === 'active' ? 'No active tasks. Tab = show all.' : 'No background tasks in this session.'; - const lines: string[] = [chalk.hex(this.props.colors.textMuted)(empty)]; + const lines: string[] = [currentTheme.fg('textMuted', empty)]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame(title, lines, width, height); } @@ -462,24 +467,23 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { - const colors = this.props.colors; const pointer = selected ? `${SELECT_POINTER} ` : ' '; - const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); + const pointerStyled = currentTheme.fg(selected ? 'primary' : 'textDim', pointer); const idColor = selected - ? colors.primary + ? 'primary' : task.kind === 'agent' - ? colors.success + ? 'success' : task.kind === 'question' - ? colors.warning - : colors.accent; + ? 'warning' + : 'accent'; const idText = selected - ? chalk.hex(idColor).bold(task.taskId) - : chalk.hex(idColor)(task.taskId); + ? currentTheme.boldFg(idColor, task.taskId) + : currentTheme.fg(idColor, task.taskId); const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); const status = STATUS_LABEL[task.status]; - const statusBadge = chalk.hex(statusColor(colors, task.status))(status); + const statusBadge = currentTheme.fg(statusColor(task.status), status); const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; const prefixWidth = visibleWidth(prefix); @@ -491,7 +495,7 @@ export class TasksBrowserApp extends Container implements Focusable { (task.kind === 'process' ? singleLine(task.command) : '') || '(no description)'; const desc = truncateToWidth(description, descBudget, ELLIPSIS); - return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); + return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth); } private adjustScroll(visibleRows: number): void { @@ -523,22 +527,21 @@ export class TasksBrowserApp extends Container implements Focusable { } private renderDetailFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const empty = chalk.hex(colors.textMuted)('Select a task from the list.'); + const empty = currentTheme.fg('textMuted', 'Select a task from the list.'); const lines: string[] = [empty]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } - const label = (text: string): string => chalk.hex(colors.textMuted)(text.padEnd(14)); - const value = (text: string): string => chalk.hex(colors.text)(text); + const label = (text: string): string => currentTheme.fg('textMuted', text.padEnd(14)); + const value = (text: string): string => currentTheme.fg('text', text); const lines: string[] = [ `${label('Task ID:')}${value(task.taskId)}`, - `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, + `${label('Status:')}${currentTheme.fg(statusColor(task.status), STATUS_LABEL[task.status])}`, `${label('Description:')}${value(singleLine(task.description) || '—')}`, ]; if (task.kind === 'process' && task.command && task.command !== task.description) { @@ -551,9 +554,9 @@ export class TasksBrowserApp extends Container implements Focusable { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } if (task.kind === 'question') { - lines.push(`${label('Questions:')}${chalk.hex(colors.textMuted)(String(task.questionCount))}`); + lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); if (task.toolCallId !== undefined) { - lines.push(`${label('Tool call:')}${chalk.hex(colors.textMuted)(task.toolCallId)}`); + lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); } } const timing = @@ -562,26 +565,25 @@ export class TasksBrowserApp extends Container implements Focusable { : task.endedAt !== null && task.endedAt !== undefined ? `finished ${formatRelativeTime(task.endedAt)}` : ''; - if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); + if (timing.length > 0) lines.push(`${label('Time:')}${currentTheme.fg('textMuted', timing)}`); if (task.kind === 'process' && task.pid > 0) { - lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); + lines.push(`${label('Pid:')}${currentTheme.fg('textMuted', String(task.pid))}`); } if (task.kind === 'process' && task.exitCode !== null) { - lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); + lines.push(`${label('Exit code:')}${currentTheme.fg('textMuted', String(task.exitCode))}`); } if (task.stopReason !== undefined && task.stopReason.length > 0) { - lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); + lines.push(`${label('Reason:')}${currentTheme.fg('textMuted', task.stopReason)}`); } while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } private renderPreviewFrame(width: number, height: number): string[] { - const colors = this.props.colors; const innerHeight = Math.max(0, height - 2); const task = this.sortedVisible[this.selectedIndex]; if (task === undefined) { - const lines: string[] = [chalk.hex(colors.textMuted)('No task selected.')]; + const lines: string[] = [currentTheme.fg('textMuted', 'No task selected.')]; while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Preview Output', lines, width, height); } @@ -594,7 +596,7 @@ export class TasksBrowserApp extends Container implements Focusable { const rawLines = body.split('\n'); const tailLines = rawLines.slice(-innerHeight); - const styled = tailLines.map((line) => chalk.hex(colors.textDim)(line)); + const styled = tailLines.map((line) => currentTheme.fg('textDim', line)); while (styled.length < innerHeight) styled.push(''); return this.renderFrame('Preview Output', styled, width, height); } @@ -603,7 +605,8 @@ export class TasksBrowserApp extends Container implements Focusable { private renderTooSmall(width: number, rows: number): string[] { const lines: string[] = []; - const msg = chalk.hex(this.props.colors.error)( + const msg = currentTheme.fg( + 'error', `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, ); lines.push(fitExactly(msg, width)); diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts index 8d6381c61..ecd3953fd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -1,7 +1,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; -import type { Theme } from '#/tui/theme/index'; +import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; +import type { ThemeName } from '#/tui/theme/index'; const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto (match terminal)' }, @@ -9,26 +9,25 @@ const THEME_OPTIONS: readonly ChoiceOption[] = [ { value: 'light', label: 'Light' }, ]; -function isThemeChoice(value: string): value is Theme { - return value === 'auto' || value === 'dark' || value === 'light'; -} - export interface ThemeSelectorOptions { - readonly currentValue: Theme; - readonly colors: ColorPalette; - readonly onSelect: (theme: Theme) => void; + readonly currentValue: ThemeName; + readonly onSelect: (theme: ThemeName) => void; readonly onCancel: () => void; } export class ThemeSelectorComponent extends ChoicePickerComponent { constructor(opts: ThemeSelectorOptions) { + const customThemes = listCustomThemesSync(); + const options: ChoiceOption[] = [ + ...THEME_OPTIONS, + ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ]; super({ title: 'Select theme', - options: [...THEME_OPTIONS], + options, currentValue: opts.currentValue, - colors: opts.colors, onSelect: (value) => { - if (isThemeChoice(value)) opts.onSelect(value); + opts.onSelect(value); }, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts new file mode 100644 index 000000000..37320f92b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -0,0 +1,120 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_VISIBLE_CHOICES = 5; +const PREFERRED_SELECTED_OFFSET = 2; + +export interface UndoChoice { + readonly id: string; + readonly count: number; + readonly input: string; + readonly label: string; +} + +export interface UndoSelectorOptions { + readonly choices: readonly UndoChoice[]; + readonly onSelect: (choice: UndoChoice) => void; + readonly onCancel: () => void; +} + +export class UndoSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: UndoSelectorOptions; + private readonly list: SearchableList<UndoChoice>; + private submitted = false; + + constructor(opts: UndoSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.choices, + toSearchText: (choice) => choice.label, + initialIndex: Math.max(0, opts.choices.length - 1), + }); + } + + handleInput(data: string): void { + if (this.submitted) return; + + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + if (this.list.handleKey(data)) { + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.list.selected(); + if (selected !== undefined) { + this.submitted = true; + this.opts.onSelect(selected); + } + } + } + + override render(width: number): string[] { + const view = this.list.view(); + const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ' Select messages to undo'), + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + '', + ]; + + if (view.items.length === 0) { + lines.push(currentTheme.fg('textMuted', ' No messages')); + } else { + const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length); + const maxStart = view.items.length - visibleCount; + const start = Math.min( + Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET), + maxStart, + ); + const end = start + visibleCount; + + for (let i = start; i < end; i++) { + const choice = view.items[i]; + if (choice === undefined) continue; + lines.push( + this.renderChoiceLine(choice, i === view.selectedIndex, i > view.selectedIndex, width), + ); + } + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderChoiceLine( + choice: UndoChoice, + isSelected: boolean, + inUndoRange: boolean, + width: number, + ): string { + const pointer = isSelected ? SELECT_POINTER : ' '; + const prefix = ` ${pointer} `; + const labelBudget = Math.max(8, width - visibleWidth(prefix)); + const label = truncateToWidth(choice.label, labelBudget, '…'); + const token = isSelected ? 'primary' : inUndoRange ? 'textDim' : 'text'; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix); + line += isSelected + ? currentTheme.boldFg(token, label) + : currentTheme.fg(token, label); + return line; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts index a57ca7b86..35055e084 100644 --- a/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/update-preference-selector.ts @@ -1,7 +1,5 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -import type { ColorPalette } from '#/tui/theme/colors'; - const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ { value: 'on', @@ -17,7 +15,6 @@ const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ export interface UpdatePreferenceSelectorOptions { readonly currentValue: boolean; - readonly colors: ColorPalette; readonly onSelect: (value: boolean) => void; readonly onCancel: () => void; } @@ -28,7 +25,6 @@ export class UpdatePreferenceSelectorComponent extends ChoicePickerComponent { title: 'Automatic updates', options: [...UPDATE_PREFERENCE_OPTIONS], currentValue: opts.currentValue ? 'on' : 'off', - colors: opts.colors, onSelect: (value) => { opts.onSelect(value === 'on'); }, diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 6966dc50e..e3532b157 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -2,11 +2,24 @@ * Custom editor extending pi-tui Editor with app-level keybindings. */ -import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { + Editor, + isKeyRelease, + matchesKey, + Key, + SelectList, + visibleWidth, + type SelectItem, + type TUI, +} from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; +import { isInsideTmux } from '#/tui/utils/terminal-notification'; + +import { extractAtPrefix } from './file-mention-provider'; +import { WrappingSelectList } from './wrapping-select-list'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -32,6 +45,22 @@ interface AutocompleteInternals { readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>; } +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 = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +} as const; + /** * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol * is active AND caps_lock is on. In that state terminals emit, e.g., @@ -85,21 +114,27 @@ function stripSgr(s: string): string { return s.replace(ANSI_SGR, ''); } -function getNewlineInput(data: string): string | undefined { - if (data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~') return data; - if (matchesKey(data, Key.ctrl('j'))) return '\n'; - return undefined; +interface CustomEditorOptions { + disablePasteBurst?: boolean; } 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; public onOpenExternalEditor?: () => void; public onCtrlS?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; + /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ + public onToggleTodoExpand?: () => boolean; public onUndo?: () => void; - public onInsertNewline?: () => void; public onTextPaste?: () => void; /** * Called when ↑ is pressed in an empty editor. Return `true` to consume @@ -109,6 +144,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; /** @@ -123,27 +162,61 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; + private argumentHints: ReadonlyMap<string, string> = new Map(); + private autocompleteWasShowing = false; - /** - * `colors` is the live `ColorPalette` reference — the host mutates it - * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so - * reading `this.colors.<token>` at render time always sees the - * current theme without any setter plumbing. The `EditorTheme` that - * pi-tui's `Editor` requires is derived from the same palette, and - * `paddingX: 2` reserves the two leading columns where `render()` - * paints the terminal-style `> ` prompt — both are implementation - * details, not caller knobs. - */ - constructor( - tui: TUI, - private readonly colors: ColorPalette, - ) { + setArgumentHints(hints: ReadonlyMap<string, string>): void { + this.argumentHints = hints; + } + + constructor(tui: TUI, options: CustomEditorOptions = {}) { // paddingX: 4 reserves column 0 for the left vertical border (│), // column 1 as a single space between border and prompt, column 2 for // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - super(tui, createEditorTheme(colors), { paddingX: 4 }); + const theme = createEditorTheme(); + super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst }); + + // pi-tui keeps `createAutocompleteList` private; shadow it with an + // instance property so slash command menus render descriptions wrapped + // to at most two lines. Non-slash completion (paths, @ mentions) keeps + // pi-tui's single-line list. + (this as unknown as AutocompleteListFactoryInternals).createAutocompleteList = ( + prefix, + items, + ) => { + if (prefix.startsWith('/')) { + return new WrappingSelectList( + items, + this.getAutocompleteMaxVisible(), + theme.selectList, + SLASH_COMMAND_SELECT_LIST_LAYOUT, + ); + } + return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); + }; + + // 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 }); + }; + } + + override setDisablePasteBurst(disabled: boolean): void { + super.setDisablePasteBurst(disabled); + } + + public setInputMode(mode: 'prompt' | 'bash'): void { + if (this.inputMode === mode) return; + this.inputMode = mode; + this.onInputModeChange?.(mode); } private expandPasteMarkerAtCursor(): boolean { @@ -185,25 +258,68 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } + // Force a full re-render when the autocomplete dropdown closes, so the editor + // snaps back to the bottom instead of sitting where the taller dropdown left it. + // Only worthwhile when the session content already overflows one screen; below + // that a full clear + home would pull the editor to the top and leave a blank + // tail. Always skipped inside tmux, whose own reflow handles the shrink. + private requestFullRenderOnAutocompleteClose(): void { + if (isInsideTmux()) return; + const { columns, rows } = this.tui.terminal; + // Redraw when content fills or overflows the viewport. An exact fill (== + // rows) is safe to clear (no blank tail) and still needs the redraw: the + // differential renderer keeps the old viewport offset after a shrink. + if (this.tui.render(columns).length < rows) return; + this.tui.requestRender(true); + } + + // Detect an autocomplete open→close edge from a render frame and force a full + // re-render. Running from render() (not handleInput) also catches asynchronous + // closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels + // the menu once the provider re-query resolves. The render request is deferred + // to a microtask so the overflow probe inside the helper does not re-enter + // render() synchronously. + private trackAutocompleteCloseForFullRender(): void { + const showing = this.isShowingAutocomplete(); + const closed = this.autocompleteWasShowing && !showing; + this.autocompleteWasShowing = showing; + if (closed) { + queueMicrotask(() => this.requestFullRenderOnAutocompleteClose()); + } + } + override render(width: number): string[] { + this.trackAutocompleteCloseForFullRender(); 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]; if (original !== undefined) { - const highlighted = highlightFirstSlashToken(original, this.colors.primary); + const highlighted = highlightFirstSlashToken(original, 'primary'); if (highlighted !== undefined) { lines[firstContentIdx] = highlighted; } } } + const hint = this.computeArgumentHint(); + if (hint !== undefined) { + const line = lines[firstContentIdx]; + if (line !== undefined) { + lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width); + } + } 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; } @@ -214,15 +330,40 @@ 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; + const cmd = match[1]; + const trailingSpace = match[2] ?? ''; + if (cmd === undefined) return undefined; + const hint = this.argumentHints.get(cmd); + if (hint === undefined) return undefined; + const { line, col } = this.getCursor(); + if (line !== 0) return undefined; + const currentLine = this.getLines()[0] ?? ''; + if (col !== currentLine.length) return undefined; + return trailingSpace.length > 0 ? hint : ` ${hint}`; + } + override handleInput(data: string): void { const normalized = normalizeCapsLockedCtrl(data); if (isKeyRelease(normalized)) { 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) { @@ -293,6 +434,19 @@ export class CustomEditor extends Editor { return; } + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('t'))) { + // Only consume the key when the todo list actually has overflow to + // expand/collapse; otherwise fall through to the editor default. + if (this.onToggleTodoExpand?.() === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; @@ -302,10 +456,16 @@ export class CustomEditor extends Editor { this.onUndo?.(); } - const newlineInput = getNewlineInput(normalized); - if (newlineInput !== undefined) { - this.onInsertNewline?.(); - super.handleInput(newlineInput); + // 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; } @@ -331,7 +491,96 @@ export class CustomEditor extends Editor { return; } + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + 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(); + } + + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + 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; + } + + // 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(' ') + ) { + trigger(); + } } } @@ -342,7 +591,7 @@ export class CustomEditor extends Editor { * locate `/` via visible-index math so ANSI pass-through survives. * Returns `undefined` if no token is found. */ -export function highlightFirstSlashToken(line: string, hex: string): string | undefined { +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { const visible = stripSgr(line); const slashIdx = visible.indexOf('/'); if (slashIdx < 0) return undefined; @@ -364,7 +613,7 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un if (visibleToken === '/goal') { ranges.push(...goalCommandPathRanges(visible, endVisible)); } - return highlightVisibleRanges(line, ranges, hex); + return highlightVisibleRanges(line, ranges, token); } function goalCommandPathRanges( @@ -383,10 +632,7 @@ function goalCommandPathRanges( return ranges; } -function readTokenRange( - visible: string, - start: number, -): { start: number; end: number } | null { +function readTokenRange(visible: string, start: number): { start: number; end: number } | null { let tokenStart = start; while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; if (tokenStart >= visible.length) return null; @@ -402,7 +648,7 @@ function isTokenSpace(ch: string | undefined): boolean { function highlightVisibleRanges( line: string, ranges: Array<{ start: number; end: number }>, - hex: string, + token: 'primary', ): string { let out = ''; let rawCursor = 0; @@ -410,12 +656,59 @@ function highlightVisibleRanges( const rawStart = mapVisibleIdxToRaw(line, range.start); const rawEnd = mapVisibleIdxToRaw(line, range.end); out += line.slice(rawCursor, rawStart); - out += chalk.hex(hex).bold(line.slice(rawStart, rawEnd)); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); rawCursor = rawEnd; } return out + line.slice(rawCursor); } +// Mirrors the editor's paddingX (see constructor). The hint is spliced into +// the first content line, which starts with this many spaces of left padding. +const EDITOR_LEFT_PADDING = 4; +// pi-tui renders the end-of-input cursor as an inverse-video space. +const CURSOR_BLOCK = '\u001B[7m \u001B[0m'; + +/** + * Splice a dimmed argument-hint ghost string into the first content line. + * + * The hint is purely visual: it is appended after the typed command (and + * after the cursor block when one is rendered) so the cursor stays at the + * end of the real input. It consumes trailing padding space, so the line + * width is preserved; if it would overflow the box it is truncated with an + * ellipsis. Returns the line unchanged when there is no room for a hint. + */ +function injectArgumentHint( + line: string, + hint: string, + realTextLength: number, + width: number, +): string { + const cursorIdx = line.indexOf(CURSOR_BLOCK); + const cursorPresent = cursorIdx !== -1; + const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2); + // Room left in the content area after the typed text (and cursor). The hint + // must fit within this so the rendered line keeps its width. + const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0); + const trimmed = truncateHint(hint, available); + if (trimmed.length === 0) return line; + const colored = currentTheme.fg('textDim', trimmed); + const insertAt = cursorPresent + ? cursorIdx + CURSOR_BLOCK.length + : mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength); + // Everything after the insertion point is trailing padding + right padding + // (plain spaces). Replace it with the hint followed by the remaining spaces + // so the visible line width is preserved. + const trailing = line.length - insertAt; + return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length)); +} + +function truncateHint(hint: string, maxLen: number): string { + if (maxLen <= 0) return ''; + if (hint.length <= maxLen) return hint; + if (maxLen === 1) return '…'; + return `${hint.slice(0, maxLen - 1)}…`; +} + /** * Overlay a terminal-style `> ` prompt symbol on the first content line. * Column 0 is reserved for the left vertical border (overlaid later by @@ -426,12 +719,17 @@ function highlightVisibleRanges( * 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); } /** @@ -445,29 +743,44 @@ 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; const firstCh = line[0]; const lastCh = line.at(-1); const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); - const tail = - line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); if (line.length === 1) return head; return head + line.slice(1, -1) + tail; }); diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index dda016a2f..722682db6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -1,41 +1,62 @@ -import { readdirSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, + fuzzyMatch, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); const MAX_FALLBACK_SCAN = 2000; const MAX_FALLBACK_SUGGESTIONS = 50; +export interface SlashAutocompleteCommand extends SlashCommand { + readonly aliases?: readonly string[]; +} + interface FsMentionCandidate { readonly path: string; + readonly absolutePath: string; readonly isDirectory: boolean; } /** * Kimi wrapper around pi-tui's combined autocomplete provider. * - * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available. While managed fd is downloading (or when it is unavailable), a - * small filesystem fallback keeps basic `@` file and folder completion usable. - * Ordinary path completion is still handled by pi-tui's readdir-backed path - * completer. This wrapper also keeps Kimi-specific slash-command guards. + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Kimi-specific slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( - slashCommands: SlashCommand[], + private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', ) { - this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); + // Build an expanded list that includes alias entries so that + // inner's argument completion can find commands by alias too. + const expanded: SlashAutocompleteCommand[] = []; + for (const cmd of slashCommands) { + expanded.push(cmd); + for (const alias of cmd.aliases ?? []) { + expanded.push({ ...cmd, name: alias }); + } + } + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -47,6 +68,38 @@ export class FileMentionProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); + // `@` 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) { + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + try { + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } catch { + // If fd fails to spawn unexpectedly, keep @ completion usable. + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); + } + } + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { return null; } @@ -61,21 +114,86 @@ export class FileMentionProvider implements AutocompleteProvider { return null; } - const atPrefix = extractAtPrefix(textBeforeCursor); - if (atPrefix !== null) { - if (this.fdPath === null) { - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + // Handle slash-command name completion ourselves so that aliases are + // searchable and visible in the label. + if (!options.force && textBeforeCursor.startsWith('/')) { + const spaceIndex = textBeforeCursor.indexOf(' '); + if (spaceIndex === -1) { + const tokens = textBeforeCursor + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + type SlashMatch = { + cmd: SlashAutocompleteCommand; + score: number; + viaAlias: boolean; + label: string; + }; + const matches: SlashMatch[] = []; + + for (const cmd of this.slashCommands) { + const nameScore = scoreTokens(tokens, cmd.name); + if (nameScore !== null) { + matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); + continue; + } + // Aliases only count when the primary name missed; the label then + // lists them so the user can see why the command matched. + const aliases = cmd.aliases ?? []; + let bestAliasScore: number | null = null; + for (const alias of aliases) { + const aliasScore = scoreTokens(tokens, alias); + if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { + bestAliasScore = aliasScore; + } + } + if (bestAliasScore !== null) { + matches.push({ + cmd, + score: bestAliasScore, + viaAlias: true, + label: `${cmd.name} (${aliases.join(', ')})`, + }); + } + } + + // Primary-name matches outrank alias matches on score ties. + matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + prefix: textBeforeCursor, + }; } - try { - return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); - } catch { - // If fd fails to spawn unexpectedly, keep @ completion usable. - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + } + + // 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; } @@ -88,11 +206,20 @@ 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); } } -function extractAtPrefix(text: string): string | null { +export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { if (PATH_DELIMITERS.has(text[i] ?? '')) { @@ -104,15 +231,73 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * 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[], atPrefix: string, signal: AbortSignal, ): AutocompleteSuggestions | null { if (signal.aborted) return null; const query = atPrefix.slice(1); - const candidates = collectFsMentionCandidates(workDir, signal); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); if (candidates.length === 0 || signal.aborted) return null; const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); @@ -124,44 +309,69 @@ function getFsMentionSuggestions( }; } -function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] { - const result: FsMentionCandidate[] = []; - const stack = ['']; +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; - while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) { - if (signal.aborted) break; - const relativeDir = stack.pop() ?? ''; - const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir); - let entries; - try { - entries = readdirSync(absoluteDir, { withFileTypes: true }); - } catch { - continue; - } + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; - for (const entry of entries) { - if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break; - if (entry.name === '.git') continue; - - const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); - const isSymlink = entry.isSymbolicLink(); - let isDirectory = entry.isDirectory(); - if (!isDirectory && isSymlink) { - try { - isDirectory = statSync(join(workDir, relativePath)).isDirectory(); - } catch { - // Broken symlink or permission error — keep it as a file candidate. - } + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; } - result.push({ path: relativePath, isDirectory }); - if (isDirectory && !isSymlink) { - stack.push(relativePath); + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } } } } - return result; + return [...candidatesByAbsolutePath.values()]; } function rankFsMentionCandidates( @@ -211,7 +421,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { return { value, label, - description: valuePath, + description: candidate.absolutePath, }; } @@ -219,6 +429,52 @@ function normalizePath(path: string): string { return path.replaceAll('\\', '/'); } +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], + textBeforeCursor: string, +): Promise<AutocompleteSuggestions | null> { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; + + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; + + return { + prefix: parsed.argumentPrefix, + items, + }; +} + +function parseSlashArgumentContext( + textBeforeCursor: string, + slashCommands: readonly SlashAutocompleteCommand[], +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; + } + + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; +} + +function findSlashCommand( + slashCommands: readonly SlashAutocompleteCommand[], + commandName: string, +): SlashAutocompleteCommand | undefined { + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + function shouldSuppressLeadingWhitespaceSlashPath( textBeforeCursor: string, force: boolean | undefined, @@ -238,3 +494,32 @@ function shouldSuppressSlashArgumentCompletion( if (!textBeforeCursor.includes(' ')) return false; return textAfterCursor.trimStart().length > 0; } + +/** + * All tokens must fuzzy-match `text`; returns the summed score, or null when + * any token misses. An empty token list matches everything with score 0. + * Mirrors pi-tui fuzzyFilter's token semantics — keep in sync if it changes. + */ +function scoreTokens(tokens: readonly string[], text: string): number | null { + let score = 0; + for (const token of tokens) { + const m = fuzzyMatch(token, text); + if (!m.matches) return null; + score += m.score; + } + return score; +} + +/** + * Mirrors CombinedAutocompleteProvider's description rendering so the + * intercepted name completion keeps showing the argument hint. + */ +function formatSlashCommandDescription(cmd: SlashAutocompleteCommand): string | undefined { + const desc = cmd.description ?? ''; + const full = cmd.argumentHint + ? desc + ? `${cmd.argumentHint} — ${desc}` + : cmd.argumentHint + : desc; + return full || undefined; +} diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts new file mode 100644 index 000000000..b6969c630 --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -0,0 +1,177 @@ +import { + SelectList, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type SelectItem, + type SelectListLayoutOptions, + type SelectListTheme, +} from '@moonshot-ai/pi-tui'; + +// Mirror pi-tui's private select-list layout constants +// (dist/components/select-list.js); keep in sync when bumping pi-tui. +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const DESCRIPTION_MAX_LINES = 2; +const ELLIPSIS = '…'; +const ELLIPSIS_WIDTH = visibleWidth(ELLIPSIS); + +// truncateToWidth appends an ANSI reset whenever it actually truncates. +// Labels and descriptions here are plain text, and the reset would sit +// inside the theme's colour wrapping and reset the rest of the line (e.g. +// a selected row with a truncated name loses its colour after the name), +// so strip it. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const TRAILING_ANSI_RESET = /(?:\u001B\[0m)+$/; + +function truncatePlainToWidth(text: string, maxWidth: number): string { + return truncateToWidth(text, maxWidth, '').replace(TRAILING_ANSI_RESET, ''); +} + +interface SelectListInternals { + readonly filteredItems: SelectItem[]; + readonly selectedIndex: number; + readonly maxVisible: number; + readonly theme: SelectListTheme; + readonly layout: SelectListLayoutOptions; +} + +/** + * SelectList that wraps item descriptions onto up to two lines instead of + * truncating them to one. Long command / skill descriptions stay readable; + * anything past the second line is ellipsized. + * + * Only `render` is replaced — selection, filtering, and key handling stay in + * pi-tui. pi-tui keeps the row state private, so the renderer reads it + * through a cast, the same idiom CustomEditor uses for autocomplete + * internals. + */ +export class WrappingSelectList extends SelectList { + override render(width: number): string[] { + const { filteredItems, selectedIndex, maxVisible, theme } = this.internals(); + if (filteredItems.length === 0) { + return [theme.noMatch(' No matching commands')]; + } + + const primaryColumnWidth = this.primaryColumnWidth(); + const startIndex = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), filteredItems.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, filteredItems.length); + + const lines: string[] = []; + for (let i = startIndex; i < endIndex; i++) { + const item = filteredItems[i]; + if (!item) continue; + lines.push(...this.renderItemLines(item, i === selectedIndex, width, primaryColumnWidth)); + } + + if (startIndex > 0 || endIndex < filteredItems.length) { + const scrollText = ` (${selectedIndex + 1}/${filteredItems.length})`; + lines.push(theme.scrollInfo(truncatePlainToWidth(scrollText, width - 2))); + } + return lines; + } + + private renderItemLines( + item: SelectItem, + isSelected: boolean, + width: number, + primaryColumnWidth: number, + ): string[] { + const { theme } = this.internals(); + const prefix = isSelected ? '→ ' : ' '; + const prefixWidth = visibleWidth(prefix); + const description = item.description + ? item.description.replaceAll(/[\r\n]+/g, ' ').trim() + : undefined; + + if (description && width > 40) { + const effectivePrimaryColumnWidth = Math.max( + 1, + Math.min(primaryColumnWidth, width - prefixWidth - 4), + ); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimaryValue( + item, + isSelected, + maxPrimaryWidth, + effectivePrimaryColumnWidth, + ); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = ' '.repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety, as upstream + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const descriptionLines = wrapDescription(description, remainingWidth); + const indent = ' '.repeat(descriptionStart); + if (isSelected) { + return descriptionLines.map((line, index) => + theme.selectedText(index === 0 ? `${prefix}${truncatedValue}${spacing}${line}` : indent + line), + ); + } + return descriptionLines.map((line, index) => + index === 0 + ? prefix + truncatedValue + theme.description(spacing + line) + : theme.description(indent + line), + ); + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimaryValue(item, isSelected, maxWidth, maxWidth); + return [isSelected ? theme.selectedText(`${prefix}${truncatedValue}`) : prefix + truncatedValue]; + } + + private truncatePrimaryValue( + item: SelectItem, + isSelected: boolean, + maxWidth: number, + columnWidth: number, + ): string { + const { layout } = this.internals(); + const displayValue = item.label || item.value; + const truncated = layout.truncatePrimary + ? layout.truncatePrimary({ text: displayValue, maxWidth, columnWidth, item, isSelected }) + : displayValue; + return truncatePlainToWidth(truncated, maxWidth); + } + + private primaryColumnWidth(): number { + const { filteredItems, layout } = this.internals(); + const rawMin = + layout.minPrimaryColumnWidth ?? layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + layout.maxPrimaryColumnWidth ?? layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const min = Math.max(1, Math.min(rawMin, rawMax)); + const max = Math.max(1, Math.max(rawMin, rawMax)); + const widest = filteredItems.reduce( + (acc, item) => Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), + 0, + ); + return Math.max(min, Math.min(widest, max)); + } + + private internals(): SelectListInternals { + return this as unknown as SelectListInternals; + } +} + +/** + * Wrap `text` to at most DESCRIPTION_MAX_LINES lines of `width` columns. + * When the text needs more lines, the last visible line is rebuilt from the + * remaining text and ellipsized. + */ +function wrapDescription(text: string, width: number): string[] { + const wrapped = wrapTextWithAnsi(text, width); + if (wrapped.length <= DESCRIPTION_MAX_LINES) { + return wrapped; + } + const kept = wrapped.slice(0, DESCRIPTION_MAX_LINES - 1); + const rest = wrapped.slice(DESCRIPTION_MAX_LINES - 1).join(' '); + const clipped = truncatePlainToWidth(rest, width - ELLIPSIS_WIDTH).trimEnd(); + return [...kept, `${clipped}${ELLIPSIS}`]; +} diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 5a978723d..1fec48b26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type DiffLineKind = 'context' | 'add' | 'delete'; @@ -20,14 +20,15 @@ interface DiffStyles { meta: (s: string) => string; } -function makeDiffStyles(colors: ColorPalette): DiffStyles { +function makeDiffStyles(): DiffStyles { + const palette = currentTheme.palette; return { - add: (s) => chalk.hex(colors.diffAdded)(s), - del: (s) => chalk.hex(colors.diffRemoved)(s), - addBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - delBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - gutter: (s) => chalk.hex(colors.diffGutter)(s), - meta: (s) => chalk.hex(colors.diffMeta)(s), + add: (s) => chalk.hex(palette.diffAdded)(s), + del: (s) => chalk.hex(palette.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(palette.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(palette.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(palette.diffGutter)(s), + meta: (s) => chalk.hex(palette.diffMeta)(s), }; } @@ -108,13 +109,12 @@ export function renderDiffLines( oldText: string, newText: string, path: string, - colors: ColorPalette, isIncomplete: boolean = false, oldStart?: number, newStart?: number, maxLines?: number, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); const changedLines = diffLines.filter((l) => l.kind !== 'context'); const added = changedLines.filter((l) => l.kind === 'add').length; @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; } interface Cluster { @@ -234,13 +236,18 @@ export function renderDiffLinesClustered( oldText: string, newText: string, path: string, - colors: ColorPalette, opts: ClusteredDiffOptions = {}, ): string[] { - const s = makeDiffStyles(colors); + const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; - const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); + const diffLines = computeDiffLines( + oldText, + newText, + opts.oldStart ?? 1, + opts.newStart ?? 1, + opts.isIncomplete ?? false, + ); const { clusters, changedCount, addedCount, removedCount } = buildClusters( diffLines, contextLines, diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index 86253582f..04e80f534 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -12,44 +12,78 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; const MAX_IMAGE_ROWS = 12; const MAX_IMAGE_WIDTH = 40; export class ImageThumbnail extends Container { - constructor(attachment: ImageAttachment, colors: ColorPalette) { - super(); + private readonly attachment: ImageAttachment; + private lastRenderWidth = 80; + private lastBuiltWidth: number | undefined; + private lastBuiltInline: boolean | undefined; + constructor(attachment: ImageAttachment) { + super(); + this.attachment = attachment; + this.buildChildren(this.lastRenderWidth); + } + + private buildChildren(width: number): void { + this.clear(); const caps = getCapabilities(); const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; if (!supportsInline) { - // Non-graphic terminal — show the placeholder text in dim cyan so - // it's clearly an attachment reference but doesn't shout. - this.addChild(new Text(chalk.hex(colors.accent)(attachment.placeholder), 0, 0)); + this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0)); + this.lastBuiltWidth = width; + this.lastBuiltInline = false; return; } const theme: ImageTheme = { - fallbackColor: (s: string) => chalk.hex(colors.textDim)(s), + fallbackColor: (s: string) => currentTheme.fg('textDim', s), }; - const base64 = Buffer.from(attachment.bytes).toString('base64'); + const base64 = Buffer.from(this.attachment.bytes).toString('base64'); const image = new Image( base64, - attachment.mime, + this.attachment.mime, theme, { maxHeightCells: MAX_IMAGE_ROWS, - maxWidthCells: MAX_IMAGE_WIDTH, - filename: attachment.placeholder, + maxWidthCells: Math.max(1, Math.min(MAX_IMAGE_WIDTH, width - 2)), + filename: this.attachment.placeholder, }, - { widthPx: attachment.width, heightPx: attachment.height }, + { widthPx: this.attachment.width, heightPx: this.attachment.height }, ); this.addChild(image); + this.lastBuiltWidth = width; + this.lastBuiltInline = true; + } + + override render(width: number): string[] { + const safeWidth = Math.max(0, width); + this.lastRenderWidth = safeWidth; + + if (safeWidth < MAX_IMAGE_WIDTH + 2) { + return new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0).render( + safeWidth, + ); + } + + const caps = getCapabilities(); + const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; + if (this.lastBuiltWidth !== safeWidth || this.lastBuiltInline !== supportsInline) { + this.buildChildren(safeWidth); + } + return super.render(safeWidth); + } + + override invalidate(): void { + this.buildChildren(this.lastRenderWidth); + super.invalidate(); } } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 501d1c936..6fd1624d5 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -15,33 +15,42 @@ * - Ungrouping is not implemented. Once formed, a group stays grouped. */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; const THROTTLE_MS = 200; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + interface AgentEntry { readonly toolCallId: string; readonly tc: ToolCallComponent; } +interface PhaseCounts { + readonly done: number; + readonly failed: number; + readonly backgrounded: number; + readonly running: number; + readonly waiting: number; + readonly starting: number; + readonly terminal: number; +} + export class AgentGroupComponent extends Container { private readonly entries: AgentEntry[] = []; private readonly headerText: Text; private readonly bodyContainer: Container; private throttleTimer: ReturnType<typeof setTimeout> | null = null; private lastFlushPhases = new Map<string, ToolCallSubagentSnapshot['phase']>(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -124,6 +133,9 @@ export class AgentGroupComponent extends Container { const isLast = idx === snapshots.length - 1; this.appendLines(snap, isLast); }); + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } this.lastFlushPhases.clear(); this.entries.forEach((entry, i) => { @@ -136,15 +148,13 @@ export class AgentGroupComponent extends Container { } private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { - const colors = this.colors; const total = snapshots.length; - const done = snapshots.filter((s) => s.phase === 'done').length; - const failed = snapshots.filter((s) => s.phase === 'failed').length; - const finished = done + failed; - const allDone = finished === total; + const counts = countPhases(snapshots); + const allDone = counts.terminal === total; const bullet = allDone - ? chalk.hex(colors.success)(STATUS_BULLET) - : chalk.hex(colors.roleAssistant)(STATUS_BULLET); + ? currentTheme.fg('success', STATUS_BULLET) + : currentTheme.fg('text', STATUS_BULLET); + const elapsedSeconds = maxElapsedSeconds(snapshots); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -154,33 +164,27 @@ export class AgentGroupComponent extends Container { : `${String(total)} agents finished`; const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); - const tail = formatHeaderTail(totalTools, totalTokens); - return `${bullet}${chalk.hex(colors.primary).bold(headerLabel)}${tail}`; + const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } - let headerText = `Running ${String(total)} agents`; - // Mixed status gets a breakdown so the current state is clear. - if (finished > 0) { - const running = total - finished; - const parts: string[] = []; - if (done > 0) parts.push(`${String(done)} done`); - if (failed > 0) parts.push(`${String(failed)} failed`); - if (running > 0) parts.push(`${String(running)} running`); - headerText = `Running ${String(total)} agents (${parts.join(', ')})`; - } - return `${bullet}${chalk.hex(colors.primary).bold(headerText)}`; + const parts = formatBreakdownParts(counts); + const headerText = parts.length > 0 + ? `Running ${String(total)} agents (${parts.join(', ')})` + : `Running ${String(total)} agents`; + const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string) => currentTheme.dim(text); // First-level branch line. const branch1 = isLast ? '└─' : '├─'; const agentType = snap.agentName ?? 'agent'; const desc = snap.toolCallDescription || '(no description)'; - const tail = formatLineTail(snap, colors); - const namePart = chalk.hex(colors.primary)(agentType); + const tail = formatLineTail(snap); + const namePart = currentTheme.fg('primary', agentType); const descPart = dim(`· ${desc}`); const stats = formatStats(snap); const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; @@ -191,7 +195,7 @@ export class AgentGroupComponent extends Container { if (snap.phase === 'failed') { // Show one error line; error messages can be long. const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; - const errStr = chalk.hex(colors.error)(`Error: ${errLine}`); + const errStr = currentTheme.fg('error', `Error: ${errLine}`); this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); return; } @@ -200,12 +204,36 @@ export class AgentGroupComponent extends Container { return; } // Running or not-yet-started agents show latest activity, with a fallback. - const fallback = snap.phase === 'queued' ? 'Queued…' : 'Initializing…'; - const activity = snap.latestActivity ?? fallback; + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } + /** + * Show the Ctrl+B hint while at least one agent in the group is still + * running in the foreground (i.e. can be detached). Hide it once every + * agent is done, failed, or already backgrounded. + */ + private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean { + return snapshots.some( + (s) => + s.phase === 'running' || + s.phase === 'queued' || + s.phase === 'spawning' || + s.phase === undefined, + ); + } + /** Releases throttle timers so destroyed components cannot refresh later. */ + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + dispose(): void { if (this.throttleTimer !== null) { clearTimeout(this.throttleTimer); @@ -217,32 +245,129 @@ export class AgentGroupComponent extends Container { } } -function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = chalk.dim; - const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; - const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; - return dim(`${tools}${tokens}`); +function countPhases(snapshots: readonly ToolCallSubagentSnapshot[]): PhaseCounts { + let done = 0; + let failed = 0; + let backgrounded = 0; + let running = 0; + let waiting = 0; + let starting = 0; + + for (const snap of snapshots) { + switch (snap.phase) { + case 'done': + done += 1; + break; + case 'failed': + failed += 1; + break; + case 'backgrounded': + backgrounded += 1; + break; + case 'queued': + waiting += 1; + break; + case 'running': + running += 1; + break; + case 'spawning': + case undefined: + starting += 1; + break; + } + } + + return { + done, + failed, + backgrounded, + running, + waiting, + starting, + terminal: done + failed + backgrounded, + }; } -function formatLineTail(snap: ToolCallSubagentSnapshot, colors: ColorPalette): string { - if (snap.phase === 'done') { - return chalk.dim(' · ') + chalk.hex(colors.success)('✓ Completed'); - } - if (snap.phase === 'failed') { - return chalk.dim(' · ') + chalk.hex(colors.error)('✗ Failed'); - } - if (snap.phase === 'backgrounded') { - return chalk.dim(' · ◐ backgrounded'); - } - return ''; -} - -function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = chalk.dim; +function formatBreakdownParts(counts: PhaseCounts): string[] { const parts: string[] = []; - if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); - if (tokens > 0) parts.push(formatTokens(tokens)); - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + if (counts.done > 0) parts.push(`${String(counts.done)} done`); + if (counts.failed > 0) parts.push(`${String(counts.failed)} failed`); + if (counts.backgrounded > 0) parts.push(`${String(counts.backgrounded)} backgrounded`); + if (counts.running > 0) parts.push(`${String(counts.running)} running`); + if (counts.waiting > 0) parts.push(`${String(counts.waiting)} waiting`); + if (counts.starting > 0) parts.push(`${String(counts.starting)} starting`); + return parts; +} + +function formatStats(snap: ToolCallSubagentSnapshot): string { + const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); + if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); + return currentTheme.dim(` · ${parts.join(' · ')}`); +} + +function formatLineTail(snap: ToolCallSubagentSnapshot): string { + const separator = currentTheme.dim(' · '); + switch (snap.phase) { + case 'done': + return separator + currentTheme.fg('success', '✓ Completed'); + case 'failed': + return separator + currentTheme.fg('error', '✗ Failed'); + case 'backgrounded': + return separator + currentTheme.dim('◐ backgrounded'); + case 'queued': + return separator + currentTheme.fg('primary', 'Waiting'); + case 'running': + return separator + currentTheme.fg('primary', 'Running'); + case 'spawning': + case undefined: + return separator + currentTheme.fg('primary', 'Starting'); + } +} + +function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): string { + switch (phase) { + case 'queued': + return 'Waiting to start…'; + case 'running': + return 'Still working…'; + case 'spawning': + case undefined: + return 'Starting…'; + case 'done': + case 'failed': + case 'backgrounded': + return ''; + } +} + +function formatHeaderTail(args: { + readonly toolCount: number; + readonly tokens: number; + readonly elapsedSeconds: number | undefined; +}): string { + const parts: string[] = []; + if (args.toolCount > 0) parts.push(`${String(args.toolCount)} tool${args.toolCount === 1 ? '' : 's'}`); + if (args.tokens > 0) parts.push(formatTokens(args.tokens)); + if (args.elapsedSeconds !== undefined) parts.push(formatElapsed(args.elapsedSeconds)); + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; +} + +function maxElapsedSeconds(snapshots: readonly ToolCallSubagentSnapshot[]): number | undefined { + let max: number | undefined; + for (const snap of snapshots) { + const elapsed = snap.elapsedSeconds; + if (elapsed === undefined) continue; + max = max === undefined ? elapsed : Math.max(max, elapsed); + } + return max; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; } function formatTokens(n: number): string { diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index c7b8cae9c..75c7266a2 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { @@ -6,6 +6,7 @@ import { type AgentSwarmProgressEstimatorPhase, } from '#/tui/components/messages/agent-swarm-progress-estimator'; import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import { gradientText } from '#/tui/theme/gradient-text'; @@ -169,7 +170,6 @@ export interface AgentSwarmGridLayout { export interface AgentSwarmProgressOptions { readonly description: string; - readonly colors: ColorPalette; readonly requestRender?: () => void; readonly availableGridHeight?: () => number | undefined; } @@ -188,7 +188,6 @@ export class AgentSwarmProgressComponent implements Component { private members: AgentSwarmMember[]; private readonly progressEstimator = new AgentSwarmProgressEstimator(); private description: string; - private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; private inputComplete = false; @@ -202,12 +201,16 @@ export class AgentSwarmProgressComponent implements Component { constructor(options: AgentSwarmProgressOptions) { this.description = options.description; - this.colors = options.colors; this.requestRender = options.requestRender; this.availableGridHeight = options.availableGridHeight; this.members = []; } + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + dispose(): void { if (this.timer === undefined) return; clearInterval(this.timer); diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 1be89b2ca..c1b39537d 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,59 +5,119 @@ * to align after the bullet. */ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; -import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +type AssistantMarkdownOptions = { + transient?: boolean; +}; export class AssistantMessageComponent implements Component { private contentContainer: Container; - private markdownTheme: MarkdownTheme; - private bulletColor: string; + private markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; - constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { - this.markdownTheme = markdownTheme; - this.bulletColor = colors.roleAssistant; + 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, this.markdownTheme)); + 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 { - this.contentContainer.invalidate?.(); + // Markdown caches ANSI colour codes keyed on (text, width). When the + // theme changes the cached strings contain stale colours, so we rebuild + // the Markdown child with the new theme while preserving transient mode. + this.markRenderDirty(); + this.contentContainer.clear(); + this.markdown = undefined; + + if (this.lastText.trim().length > 0) { + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), + ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); + } } render(width: number): string[] { if (this.lastText.trim().length === 0) return []; + 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, width - visibleWidth(prefix)); + const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); const contentLines = this.contentContainer.render(contentWidth); const lines: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { const p = - i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : MESSAGE_INDENT; + i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - return lines; + const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index c1086f805..9c1a3d815 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,42 +1,41 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { BackgroundAgentStatusData } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { - constructor( - private readonly data: BackgroundAgentStatusData, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly data: BackgroundAgentStatusData) {} invalidate(): void {} render(width: number): string[] { - const tone = + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const tone: keyof ColorPalette = this.data.phase === 'started' - ? this.colors.primary + ? 'primary' : this.data.phase === 'completed' - ? this.colors.success - : this.colors.error; + ? 'success' + : 'error'; const bullet = - this.data.phase === 'failed' ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET); + this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); const text = - chalk.hex(tone)(this.data.headline) + + currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 - ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) + ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); const textComponent = new Text(text, 0, 0); - const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); + const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length); const contentLines = textComponent.render(contentWidth); return [ '', ...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line), - ]; + ].map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 075ce3378..8cb81f5d3 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,54 +1,67 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { CronTranscriptData } from '#/tui/types'; export class CronMessageComponent implements Component { private readonly spacer = new Spacer(1); + private readonly data: CronTranscriptData; private readonly title: string; private readonly detail: string | undefined; - private readonly titleColor: string; private readonly promptText: Text; + private readonly prompt: string; constructor( prompt: string, data: CronTranscriptData, - private readonly colors: ColorPalette, ) { const missed = data.missedCount !== undefined; + this.data = data; this.title = missed ? 'Missed scheduled reminders' : 'Scheduled reminder fired'; this.detail = cronDetail(data); - this.titleColor = data.stale === true || missed ? colors.warning : colors.accent; - this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0); + this.prompt = prompt; + this.promptText = new Text(currentTheme.fg('text', prompt), 0, 0); } invalidate(): void { + this.promptText.setText(currentTheme.fg('text', this.prompt)); this.promptText.invalidate(); } render(width: number): string[] { - const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const missed = this.data.missedCount !== undefined; + const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent'; + const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET); const bulletWidth = visibleWidth(bullet); - const contentWidth = Math.max(1, width - bulletWidth); + const contentWidth = Math.max(1, safeWidth - bulletWidth); + const continuationIndent = ' '.repeat(bulletWidth); const lines: string[] = []; - for (const line of this.spacer.render(width)) { + for (const line of this.spacer.render(safeWidth)) { lines.push(line); } - const title = chalk.hex(this.titleColor).bold(this.title); - lines.push(`${bullet}${title}`); + const titleLines = new Text(currentTheme.boldFg(titleToken, this.title), 0, 0).render(contentWidth); + for (let i = 0; i < titleLines.length; i += 1) { + lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`); + } if (this.detail !== undefined) { - lines.push(`${' '.repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`); + const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth); + for (const line of detailLines) { + lines.push(`${continuationIndent}${line}`); + } } const promptLines = this.promptText.render(contentWidth); for (const line of promptLines) { - lines.push(`${' '.repeat(bulletWidth)}${line}`); + lines.push(`${continuationIndent}${line}`); } return lines; diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 97bd2c2c8..f4482941f 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -7,12 +7,12 @@ * the richer completion card (the `/goal` box), not this marker. */ -import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const HEAD_INDENT = ' '; const DETAIL_INDENT = ' '; @@ -21,7 +21,7 @@ type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system'; interface GoalMarkerOptions { readonly marker?: string; - readonly textHex?: string; + readonly textToken?: ColorToken; readonly expandable?: boolean; readonly indent?: string; readonly leadingBlank?: boolean; @@ -30,7 +30,7 @@ interface GoalMarkerOptions { export class GoalMarkerComponent implements Component { private expanded = false; private readonly marker: string; - private readonly textHex: string; + private readonly textToken: ColorToken; private readonly expandable: boolean; private readonly indent: string; private readonly leadingBlank: boolean; @@ -38,12 +38,11 @@ export class GoalMarkerComponent implements Component { constructor( private readonly headline: string, private readonly detail: string | undefined, - private readonly colors: ColorPalette, - private readonly accentHex: string, + private readonly accentToken: ColorToken, options: GoalMarkerOptions = {}, ) { this.marker = options.marker ?? '◦'; - this.textHex = options.textHex ?? colors.textDim; + this.textToken = options.textToken ?? 'textDim'; this.expandable = options.expandable ?? true; this.indent = options.indent ?? HEAD_INDENT; this.leadingBlank = options.leadingBlank ?? false; @@ -56,25 +55,32 @@ export class GoalMarkerComponent implements Component { } render(width: number): string[] { - const dot = chalk.hex(this.accentHex)(this.marker); - const head = chalk.hex(this.textHex)(this.headline); + const dot = currentTheme.fg(this.accentToken, this.marker); + const head = currentTheme.fg(this.textToken, this.headline); const hasDetail = this.detail !== undefined && this.detail.length > 0; - if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + if (!hasDetail) return this.clampToWidth([`${this.indent}${dot} ${head}`], width); if (!this.expandable) { - return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + return this.clampToWidth([`${this.indent}${dot} ${head}`], width); } if (!this.expanded) { - return this.withLeadingBlank([ - `${this.indent}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`, - ]); + return this.clampToWidth( + [`${this.indent}${dot} ${head} ${currentTheme.fg('textMuted', '(ctrl+o)')}`], + width, + ); } const out = [`${this.indent}${dot} ${head}`]; const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); for (const line of wrap(this.detail!, wrapWidth)) { - out.push(DETAIL_INDENT + chalk.hex(this.colors.textDim)(line)); + out.push(DETAIL_INDENT + currentTheme.fg('textDim', line)); } - return this.withLeadingBlank(out); + return this.clampToWidth(out, width); + } + + private clampToWidth(lines: string[], width: number): string[] { + const withBlank = this.withLeadingBlank(lines); + if (width <= 0) return withBlank.map(() => ''); + return withBlank.map((line) => truncateToWidth(line, width)); } private withLeadingBlank(lines: string[]): string[] { @@ -89,17 +95,15 @@ export class GoalMarkerComponent implements Component { */ export function buildGoalMarker( change: GoalChange, - colors: ColorPalette, expanded: boolean, actor?: GoalMarkerActor, ): GoalMarkerComponent | null { - const spec = markerSpec(change, colors, actor); + const spec = markerSpec(change, actor); if (spec === null) return null; const marker = new GoalMarkerComponent( spec.headline, spec.detail ?? change.reason, - colors, - spec.accentHex, + spec.accentToken, spec.options, ); marker.setExpanded(expanded); @@ -108,23 +112,22 @@ export function buildGoalMarker( function markerSpec( change: GoalChange, - colors: ColorPalette, actor?: GoalMarkerActor, ): { headline: string; - accentHex: string; + accentToken: ColorToken; detail?: string | undefined; options?: GoalMarkerOptions | undefined; } | null { if (change.kind === 'lifecycle') { switch (change.status) { case 'paused': - return prominentMarker(pausedHeadline(change.reason, actor), colors.warning); + return prominentMarker(pausedHeadline(change.reason, actor), 'warning'); case 'active': - return prominentMarker(resumedHeadline(actor), colors.primary); + return prominentMarker(resumedHeadline(actor), 'primary'); case 'blocked': // The system stopped pursuing the goal; resumable via `/goal resume`. - return { headline: 'Goal blocked', accentHex: colors.warning }; + return { headline: 'Goal blocked', accentToken: 'warning' }; default: return null; } @@ -132,14 +135,14 @@ function markerSpec( return null; // completion -> posts its own message, not a marker } -function prominentMarker(headline: string, accentHex: string) { +function prominentMarker(headline: string, accentToken: ColorToken) { return { headline, - accentHex, + accentToken, detail: undefined, options: { marker: STATUS_BULLET.trimEnd(), - textHex: accentHex, + textToken: accentToken, expandable: false, indent: '', leadingBlank: true, @@ -167,7 +170,7 @@ function lowercaseFirst(text: string): string { } function wrap(text: string, width: number): string[] { - const words = text.replace(/\s+/g, ' ').trim().split(' '); + const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); const lines: string[] = []; let current = ''; for (const word of words) { diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index 48be7b456..d01f580fa 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -13,14 +13,19 @@ * Stop after 20 turns (7/20) (or a dim "no stop condition" note) */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text, visibleWidth } from '@earendil-works/pi-tui'; +import { + Text, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, +} from '@moonshot-ai/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { formatTokenCount } from '#/utils/usage/usage-format'; import { formatGoalElapsed } from './goal-format'; import { UsagePanelComponent } from './usage-panel'; @@ -30,10 +35,18 @@ const MAX_OBJECTIVE_LINES = 6; const MAX_CRITERION_LINES = 3; const LABEL_WIDTH = 11; -function renderLifecycleLine(label: string, colors: ColorPalette): string[] { - const marker = chalk.hex(colors.primary).bold(STATUS_BULLET); - const text = chalk.hex(colors.primary).bold(label); - return ['', marker + text]; +function renderLifecycleLine(label: string, width: number): string[] { + if (width <= 0) return ['']; + + const marker = currentTheme.boldFg('primary', STATUS_BULLET); + const text = new Text(currentTheme.boldFg('primary', label), 0, 0); + const contentWidth = Math.max(1, width - visibleWidth(STATUS_BULLET)); + return [ + '', + ...text + .render(contentWidth) + .map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line.trimEnd()), + ]; } /** @@ -42,33 +55,26 @@ function renderLifecycleLine(label: string, colors: ColorPalette): string[] { * change in the transcript. */ export class GoalSetMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} - render(_width: number): string[] { - return renderLifecycleLine('Goal set', this.colors); + render(width: number): string[] { + return renderLifecycleLine('Goal set', width); } } export class UpcomingGoalAddedMessageComponent implements Component { - constructor(private readonly colors: ColorPalette) {} - invalidate(): void {} - render(_width: number): string[] { + render(width: number): string[] { return renderLifecycleLine( 'Upcoming goal added. It will start after the current goal is complete.', - this.colors, + width, ); } } export class GoalCompletionMessageComponent implements Component { - constructor( - private readonly message: string, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly message: string) {} invalidate(): void {} @@ -76,12 +82,12 @@ export class GoalCompletionMessageComponent implements Component { const [headline = '', ...details] = this.message.trim().split(/\r?\n/); if (headline.length === 0) return []; - const bullet = chalk.hex(this.colors.success).bold(STATUS_BULLET); + const bullet = currentTheme.boldFg('success', STATUS_BULLET); const bulletWidth = visibleWidth(STATUS_BULLET); const contentWidth = Math.max(1, width - bulletWidth); const lines: string[] = ['']; - const headlineText = new Text(chalk.hex(this.colors.success).bold(headline), 0, 0); + const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); const headlineLines = headlineText.render(contentWidth); for (let i = 0; i < headlineLines.length; i += 1) { lines.push((i === 0 ? bullet : MESSAGE_INDENT) + headlineLines[i]); @@ -89,7 +95,7 @@ export class GoalCompletionMessageComponent implements Component { const detailText = details.join('\n').trim(); if (detailText.length > 0) { - const detailLines = new Text(chalk.hex(this.colors.textDim)(detailText), 0, 0).render( + const detailLines = new Text(currentTheme.fg('textDim', detailText), 0, 0).render( contentWidth, ); for (const line of detailLines) { @@ -102,35 +108,31 @@ export class GoalCompletionMessageComponent implements Component { } export class GoalStatusMessageComponent implements Component { - constructor( - private readonly goal: GoalSnapshot, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly goal: GoalSnapshot) {} invalidate(): void {} render(width: number): string[] { - const lines = buildGoalReportLines({ colors: this.colors, goal: this.goal }); - const panel = new UsagePanelComponent(lines, this.colors.primary, goalPanelTitle(this.goal)); + const panelContentWidth = Math.max(1, width - 6); + const panel = new UsagePanelComponent( + () => buildGoalReportLines(this.goal, panelContentWidth), + 'primary', + goalPanelTitle(this.goal), + ); return ['', ...panel.render(width)]; } } -export interface GoalReportOptions { - readonly colors: ColorPalette; - readonly goal: GoalSnapshot; -} - /** Box title, e.g. ` Goal · active `. */ export function goalPanelTitle(goal: GoalSnapshot): string { return ` Goal · ${goal.status} `; } -export function buildGoalReportLines(options: GoalReportOptions): string[] { - const { colors, goal } = options; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const bar = chalk.hex(statusHex(goal.status, colors)); +export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRAP_WIDTH): string[] { + const statusColor = statusToken(goal.status); + const bar = (s: string) => currentTheme.fg(statusColor, s); + const value = (s: string) => currentTheme.fg('text', s); + const muted = (s: string) => currentTheme.fg('textDim', s); // `complete` is the terminal outcome (the completion card); everything else // (active / paused / blocked) is a persisted, resumable goal that still shows // its stop condition. A reason is worth surfacing for stopped / complete states. @@ -140,12 +142,14 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] { (goal.status === 'paused' && reason !== undefined) || goal.status === 'blocked' || isComplete; const lines: string[] = []; - // Condition as a blockquote left-trail. - for (const line of wrap(goal.objective, WRAP_WIDTH, MAX_OBJECTIVE_LINES)) { + // Condition as a blockquote left-trail. Reserve the visible "▌ " prefix before + // wrapping so the panel doesn't clip rows that exactly fit the panel interior. + const blockquoteWrapWidth = Math.max(1, wrapWidth - visibleWidth('▌ ')); + for (const line of wrap(goal.objective, blockquoteWrapWidth, MAX_OBJECTIVE_LINES)) { lines.push(`${bar('▌')} ${value(line)}`); } if (goal.completionCriterion !== undefined) { - for (const line of wrap(`✓ ${goal.completionCriterion}`, WRAP_WIDTH, MAX_CRITERION_LINES)) { + for (const line of wrap(`✓ ${goal.completionCriterion}`, blockquoteWrapWidth, MAX_CRITERION_LINES)) { lines.push(`${bar('▌')} ${muted(line)}`); } } @@ -157,7 +161,7 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] { lines.push( row( 'Status', - chalk.hex(statusHex(goal.status, colors))(goal.status) + + currentTheme.fg(statusColor, goal.status) + (reason !== undefined ? muted(` — ${reason}`) : ''), ), ); @@ -192,37 +196,27 @@ function formatStopRow(goal: GoalSnapshot): string | null { return parts.length > 0 ? parts.join(', ') : null; } -function statusHex(status: GoalStatus, colors: ColorPalette): string { +function statusToken(status: GoalStatus): ColorToken { switch (status) { case 'active': - return colors.primary; + return 'primary'; case 'complete': - return colors.success; + return 'success'; case 'blocked': - return colors.warning; + return 'warning'; case 'paused': - return colors.textDim; + return 'textDim'; } } /** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */ function wrap(text: string, width: number, maxLines: number): string[] { - const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); - const lines: string[] = []; - let current = ''; - for (const word of words) { - const candidate = current.length === 0 ? word : `${current} ${word}`; - if (candidate.length > width && current.length > 0) { - lines.push(current); - current = word; - } else { - current = candidate; - } - } - if (current.length > 0) lines.push(current); + const safeWidth = Math.max(1, width); + const lines = wrapTextWithAnsi(text.replaceAll(/\s+/g, ' ').trim(), safeWidth); if (lines.length === 0) return ['']; if (lines.length <= maxLines) return lines; const clipped = lines.slice(0, maxLines); - clipped[maxLines - 1] = `${clipped[maxLines - 1]!.slice(0, Math.max(0, width - 1))}…`; + const lastLine = clipped[maxLines - 1] ?? ''; + clipped[maxLines - 1] = truncateToWidth(`${lastLine}…`, safeWidth, '…'); return clipped; } diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts index 7f7ba6003..416cf6a33 100644 --- a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -1,10 +1,8 @@ import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface McpStatusReportOptions { - readonly colors: ColorPalette; readonly servers: readonly McpServerInfo[]; } @@ -34,18 +32,17 @@ const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ function statusPainter( status: McpServerInfo['status'], - colors: ColorPalette, ): (text: string) => string { switch (status) { case 'connected': - return chalk.hex(colors.success); + return (text) => currentTheme.fg('success', text); case 'failed': - return chalk.hex(colors.error); + return (text) => currentTheme.fg('error', text); case 'needs-auth': case 'pending': - return chalk.hex(colors.warning); + return (text) => currentTheme.fg('warning', text); case 'disabled': - return chalk.hex(colors.textDim); + return (text) => currentTheme.fg('textDim', text); } } @@ -97,11 +94,10 @@ function buildSummary(servers: readonly McpServerInfo[]): string { export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { const servers = sortedServers(options.servers); - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const muted = chalk.hex(colors.textDim); - const value = chalk.hex(colors.text); - const error = chalk.hex(colors.error); + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const error = (text: string) => currentTheme.fg('error', text); const lines: string[] = [accent('Servers')]; @@ -129,7 +125,6 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri for (const server of servers) { const status = statusPainter( server.status, - colors, )(STATUS_LABEL[server.status].padEnd(statusWidth)); lines.push( ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index cbefc2300..d1eeec03c 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -7,8 +7,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; -import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; @@ -53,6 +52,12 @@ export class PlanBoxComponent implements Component { } render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + if (safeWidth < LEFT_MARGIN + 4) { + return this.markdown.render(Math.max(1, safeWidth)).map((line) => truncateToWidth(line, safeWidth, '…')); + } + if (this.cachedLines !== undefined && this.cachedWidth === width) { return this.cachedLines; } @@ -62,7 +67,7 @@ export class PlanBoxComponent implements Component { // " └──...──┘" // width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4 // content width = horzLen - 2 * SIDE_PADDING = width - 6 - const horzLen = Math.max(2, width - LEFT_MARGIN - 2); + const horzLen = Math.max(2, safeWidth - LEFT_MARGIN - 2); const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING); const paint = (s: string): string => chalk.hex(this.borderHex)(s); @@ -83,17 +88,22 @@ export class PlanBoxComponent implements Component { } lines.push(bottom); + const fitted = lines.map((line) => truncateToWidth(line, safeWidth, '…')); this.cachedWidth = width; - this.cachedLines = lines; - return lines; + this.cachedLines = fitted; + return fitted; } private buildTitle(horzLen: number): string { const fallback = ' plan '; const statusSuffix = this.buildStatusSuffix(); const fallbackWithStatus = ` plan${statusSuffix} `; - const budget = horzLen - 1; - const fallbackTitle = visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback; + const budget = Math.max(0, horzLen - 1); + const fallbackTitle = truncateToWidth( + visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback, + budget, + '…', + ); const planPath = this.planPath; if (planPath === undefined || planPath.length === 0) return fallbackTitle; const basename = path.basename(planPath); diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts new file mode 100644 index 000000000..afbc2b444 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -0,0 +1,58 @@ +/** + * Plugin command invocation card. + * + * When the user runs `/plugin:command args`, the TUI renders a compact card + * instead of expanding the command body into the user bubble: + * + * ▶ /plugin:command + * args + * + * The args line is optional. Core expands the command body into the LLM + * context; the TUI only consumes the `plugin_command.activated` event. + */ + +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const ARGS_PREVIEW_MAX = 200; + +export class PluginCommandComponent extends Container { + private headText: Text; + private previewText?: Text; + private readonly label: string; + private readonly args?: string; + + constructor(pluginId: string, commandName: string, args?: string) { + super(); + this.label = `/${pluginId}:${commandName}`; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index 2158aecd0..4654ee82d 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -1,7 +1,6 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; import { CURATED_BADGE, OFFICIAL_BADGE, @@ -12,16 +11,15 @@ import { } from '../../utils/plugin-source-label'; export interface PluginsListPanelInput { - readonly colors: ColorPalette; readonly plugins: readonly PluginSummary[]; } export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] { - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const primary = chalk.hex(input.colors.primary); - const warning = chalk.hex(input.colors.warning); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const primary = (text: string) => currentTheme.fg('primary', text); + const warning = (text: string) => currentTheme.fg('warning', text); if (input.plugins.length === 0) { return [ muted('No plugins installed.'), @@ -56,18 +54,17 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st export interface PluginsInfoPanelInput { - readonly colors: ColorPalette; readonly info: PluginInfo; } export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] { const { info } = input; - const muted = chalk.hex(input.colors.textDim); - const value = chalk.hex(input.colors.text); - const success = chalk.hex(input.colors.success); - const warning = chalk.hex(input.colors.warning); - const error = chalk.hex(input.colors.error); - const primary = chalk.hex(input.colors.primary); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const success = (text: string) => currentTheme.fg('success', text); + const warning = (text: string) => currentTheme.fg('warning', text); + const error = (text: string) => currentTheme.fg('error', text); + const primary = (text: string) => currentTheme.fg('primary', text); const status = info.enabled ? success('enabled') : muted('disabled'); const trustLine = (() => { const label = pluginTrustLabel(info); @@ -81,7 +78,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st })(); const lines: string[] = [ `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), - `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`, + `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state)}`, trustLine, `${muted('Source:')} ${value(info.source)}`, `${muted('Root:')} ${value(info.root)}`, @@ -164,7 +161,7 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st return lines; } -function stateText(state: PluginInfo['state'], colors: ColorPalette): string { - if (state === 'ok') return chalk.hex(colors.success)(state); - return chalk.hex(colors.error)(state); +function stateText(state: PluginInfo['state']): string { + if (state === 'ok') return currentTheme.fg('success', state); + return currentTheme.fg('error', state); } diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 1d48f3c37..141562e4c 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -20,12 +20,11 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; @@ -42,11 +41,9 @@ export class ReadGroupComponent extends Container { private readonly bodyContainer: Container; private throttleTimer: ReturnType<typeof setTimeout> | null = null; private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>(); + private _invalidating = false; - constructor( - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { + constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); this.headerText = new Text('', 0, 0); @@ -134,47 +131,55 @@ export class ReadGroupComponent extends Container { } private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); if (pending > 0) { - const bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Reading ${String(total)} files…`); + const bullet = currentTheme.fg('text', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); return `${bullet}${label}`; } // All reads have finished, either successfully or with failures. if (failed === total) { - const bullet = chalk.hex(colors.error)('✗ '); - const label = chalk.hex(colors.error).bold(`Read ${String(total)} files`); - return `${bullet}${label}${chalk.hex(colors.error)(' · failed')}`; + const bullet = currentTheme.fg('error', '✗ '); + const label = currentTheme.boldFg('error', `Read ${String(total)} files`); + return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; } - const bullet = chalk.hex(colors.success)(STATUS_BULLET); - const label = chalk.hex(colors.primary).bold(`Read ${String(total)} files`); + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? chalk.hex(colors.error)(` · ${String(failed)} failed`) : ''; + const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; return `${bullet}${label}${linesPart}${failPart}`; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { - const colors = this.colors; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); const branch = isLast ? '└─' : '├─'; const path = snap.filePath ?? ''; - const pathPart = chalk.hex(colors.text)(path); + const pathPart = currentTheme.fg('text', path); let tail: string; if (snap.phase === 'pending') { tail = dim(' · reading…'); } else if (snap.phase === 'failed') { - tail = chalk.hex(colors.error)(' · failed'); + tail = currentTheme.fg('error', ' · failed'); } else { tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); } return ` ${branch} ${pathPart}${tail}`; } + override invalidate(): void { + if (this._invalidating) { + super.invalidate(); + return; + } + this._invalidating = true; + this.flushRender(); + this._invalidating = false; + } + /** Releases throttle timers so destroyed components cannot refresh later. */ dispose(): void { if (this.throttleTimer !== null) { diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index bb5ecd292..cb6f95dcd 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,8 +1,7 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; @@ -12,7 +11,6 @@ import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; readonly result?: ToolResultBlockData; - readonly colors: ColorPalette; readonly expanded?: boolean; readonly showCommand?: boolean; /** @@ -22,6 +20,8 @@ export interface ShellExecutionOptions { */ readonly commandPreviewLines?: number; readonly resultPreviewLines?: number; + readonly tailOutput?: boolean; + readonly expandHint?: boolean; } export class ShellExecutionComponent extends Container { @@ -35,9 +35,10 @@ export class ShellExecutionComponent extends Container { if (options.result !== undefined) { this.addResultPreview( options.result, - options.colors, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, + options.tailOutput ?? false, + options.expandHint ?? true, ); } } @@ -47,43 +48,50 @@ export class ShellExecutionComponent extends Container { const allLines = command.split('\n'); const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { - const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(chalk.dim(prefix + line), 2, 0)); + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); } } private addResultPreview( result: ToolResultBlockData, - colors: ColorPalette, expanded: boolean, previewLines: number, + tailOutput: boolean, + expandHint: boolean, ): void { if (!result.output) return; this.addChild( new TruncatedOutputComponent(result.output, { expanded, isError: result.is_error ?? false, - colors, maxLines: previewLines, + tail: tailOutput, + expandHint, + color: 'textMuted', }), ); } } export const shellExecutionResultRenderer: ResultRenderer = ( - toolCall: ToolCallBlockData, + _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, ): Component[] => [ + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. new ShellExecutionComponent({ - command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, - colors: ctx.colors, expanded: ctx.expanded, - // Header truncates long bash commands to 60 chars. When the user expands - // the card with ctrl+o, reveal the full command (no line cap) so they - // can read what actually ran. - showCommand: ctx.expanded, - commandPreviewLines: undefined, }), ]; diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 000000000..ca99f2e76 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,134 @@ +import { Container, Text } from '@moonshot-ai/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)'; + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts index 4526328c2..f977d21d9 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -12,31 +12,53 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; const ARGS_PREVIEW_MAX = 200; export class SkillActivationComponent extends Container { + private headText: Text; + private previewText?: Text; + private name: string; + private args?: string; + constructor( name: string, args: string | undefined, - colors: ColorPalette, readonly trigger?: SkillActivationTrigger, ) { super(); + this.name = name; + this.args = args; this.addChild(new Spacer(1)); const head = - chalk.hex(colors.primary).bold('▶ Activated skill: ') + chalk.hex(colors.roleUser).bold(name); - this.addChild(new Text(head, 0, 0)); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); const trimmed = args?.trim() ?? ''; if (trimmed.length > 0) { const preview = trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; - this.addChild(new Text(' ' + chalk.hex(colors.textDim)(preview), 0, 0)); + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); } } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index be2292358..f88c1861b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,23 +1,72 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; export class StatusMessageComponent extends Container { - constructor(content: string, colors: ColorPalette, color?: string) { + private textComponent: Text; + private content: string; + private color?: ColorToken; + + constructor(content: string, color?: ColorToken) { super(); - const text = color === undefined ? chalk.hex(colors.textDim)(content) : chalk.hex(color)(content); - this.addChild(new Text(` ${text}`, 0, 0)); + this.content = content; + this.color = color; + 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 { + 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 { - constructor(title: string, detail: string | undefined, colors: ColorPalette) { + private titleText: Text; + private detailText?: Text; + private title: string; + private detail?: string; + + constructor(title: string, detail: string | undefined) { super(); + this.title = title; + this.detail = detail; this.addChild(new Spacer(1)); - this.addChild(new Text(` ${chalk.hex(colors.textStrong)(title)}`, 0, 0)); + this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); + this.addChild(this.titleText); if (detail !== undefined && detail.length > 0) { - this.addChild(new Text(` ${chalk.hex(colors.textDim)(detail)}`, 0, 0)); + this.detailText = new Text(` ${currentTheme.fg('textDim', detail)}`, 0, 0); + this.addChild(this.detailText); } } + + override invalidate(): void { + this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); + if (this.detailText !== undefined && this.detail !== undefined) { + this.detailText.setText(` ${currentTheme.fg('textDim', this.detail)}`); + } + super.invalidate(); + } } diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index c60e2f546..1d788d53b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -5,11 +5,16 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import { formatTokenCount, ratioSeverity, @@ -17,7 +22,11 @@ import { safeUsageRatio, } from '#/utils/usage/usage-format'; -import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; +import { + buildExtraUsageSection, + buildManagedUsageReportLines, + type ManagedUsageReport, +} from './usage-panel'; interface FieldRow { readonly label: string; @@ -26,13 +35,12 @@ interface FieldRow { } export interface StatusReportOptions { - readonly colors: ColorPalette; readonly version: string; readonly model: string; readonly workDir: string; readonly sessionId: string; readonly sessionTitle: string | null; - readonly thinking: boolean; + readonly thinkingEffort: ThinkingEffort; readonly permissionMode: PermissionMode; readonly planMode: boolean; readonly contextUsage: number; @@ -49,17 +57,16 @@ type Colorize = (text: string) => string; function displayModelName(alias: string, models: Record<string, ModelAlias>): string { const model = models[alias]; - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } function formatModelStatus(options: StatusReportOptions): string { const model = options.status?.model ?? options.model; if (model.trim().length === 0) return 'not set'; - const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off' - ? 'off' - : 'on'; - return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; + const effort = options.status?.thinkingEffort ?? options.thinkingEffort; + return `${displayModelName(model, options.availableModels)} (thinking ${effort})`; } function addFieldRows( @@ -89,13 +96,12 @@ function contextValues(options: StatusReportOptions): { } export function buildStatusReportLines(options: StatusReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => + sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; @@ -125,7 +131,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); - const barColoured = chalk.hex(severityHex(ratioSeverity(safeRatio)))(bar); + const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), @@ -135,7 +141,6 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -144,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 000000000..325ae6eb2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,32 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older steps within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and renders them as a single + * muted line, e.g. `… thinking 5 times, call 50 tools`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0; + } + + addCounts(thinking: number, tool: number): void { + this.thinking += thinking; + this.tool += tool; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index f24cac6b6..4fee9629f 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -1,24 +1,23 @@ -import type { Component } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; export class SwarmModeMarkerComponent implements Component { - constructor( - private readonly state: SwarmModeMarkerState, - private readonly colors: ColorPalette, - ) {} + constructor(private readonly state: SwarmModeMarkerState) {} invalidate(): void {} - render(_width: number): string[] { - const color = this.state === 'inactive' ? this.colors.textDim : this.colors.success; - const marker = chalk.hex(color).bold(STATUS_BULLET); - const label = chalk.hex(color).bold(swarmMarkerLabel(this.state)); - return ['', marker + label]; + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const token = this.state === 'inactive' ? 'textDim' : 'success'; + const marker = currentTheme.boldFg(token, STATUS_BULLET); + const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state)); + return ['', truncateToWidth(marker + label, safeWidth, '…')]; } } diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 0fee3669a..23a038c70 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -5,9 +5,7 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import type { Component, TUI } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, @@ -16,13 +14,13 @@ import { THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; export class ThinkingComponent implements Component { private text: string; - private color: string; private showMarker: boolean; private mode: ThinkingRenderMode; private expanded = false; @@ -35,15 +33,15 @@ 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, - colors: ColorPalette, showMarker: boolean = true, mode: ThinkingRenderMode = 'finalized', ui?: TUI, ) { this.text = text; - this.color = colors.roleThinking; this.showMarker = showMarker; this.mode = mode; this.ui = ui; @@ -53,20 +51,29 @@ export class ThinkingComponent implements Component { } } - invalidate(): void {} + 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)); } private styled(text: string): string { - return chalk.hex(this.color).italic(text); + return currentTheme.italicFg('textDim', text); } finalize(): void { this.mode = 'finalized'; + this.markRenderDirty(); this.stopSpinner(); } @@ -77,50 +84,70 @@ 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 ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) : contentLines; - const spinner = chalk.hex(this.color)( + const spinner = currentTheme.fg( + 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - return [ + rendered = [ '', - spinner + chalk.hex(this.color)('thinking...'), + 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 ? chalk.hex(this.color)(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; - truncated.push( - MESSAGE_INDENT + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), - ); - return truncated; + return rendered; } private startSpinner(): void { if (this.ui === undefined || this.spinnerInterval !== undefined) return; this.spinnerInterval = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.markRenderDirty(); this.ui?.requestRender(); }, BRAILLE_SPINNER_INTERVAL_MS); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index c0c8e8999..a25c88e64 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -5,13 +5,13 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, MarkdownTheme, TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - +import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, COMMAND_PREVIEW_LINES, RESULT_PREVIEW_LINES, THINKING_PREVIEW_LINES, @@ -21,11 +21,13 @@ import { STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; 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'; @@ -33,18 +35,21 @@ import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; -import { TruncatedOutputComponent } from './tool-renderers/truncated'; const MAX_ARG_LENGTH = 60; const MAX_SUB_TOOL_CALLS_SHOWN = 4; -const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; -// Hanging indent for a sub-tool's previewed output, nested under its activity row. -const SUBAGENT_SUBTOOL_OUTPUT_INDENT = 6; +// Cap the Agent `description` in the single-subagent header so a long prompt +// cannot wrap the header onto a second row and break the card's stable height. +const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; const APPROVED_PLAN_MARKER = '## Approved Plan:'; const STREAMING_PROGRESS_INTERVAL_MS = 1000; -const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; +const MAX_LIVE_OUTPUT_CHARS = 50_000; + +/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ +const DETACH_HINT_DELAY_MS = 10_000; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; type SubagentTextKind = 'thinking' | 'text'; type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; @@ -88,6 +93,7 @@ export interface ToolCallSubagentSnapshot { readonly agentName: string | undefined; readonly phase: SubagentPhase | undefined; readonly toolCount: number; + readonly elapsedSeconds: number | undefined; readonly tokens: number; readonly isError: boolean; readonly errorText: string | undefined; @@ -408,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; @@ -417,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); } @@ -458,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, @@ -466,34 +474,55 @@ class PrefixedWrappedLine implements Component { // unwrapped paragraph scrolls within a fixed window instead of growing // unbounded. The first kept row still gets `firstPrefix`. private readonly tailLines?: number, + // When set, the output is padded with empty continuation rows until it + // reaches this many display rows, so a short paragraph still fills a + // fixed-height window. Applied after `tailLines`. + private readonly minLines?: 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), ); - const contentWidth = Math.max(1, width - prefixWidth); + const contentWidth = Math.max(1, safeWidth - prefixWidth); const wrapped = new Text(this.text, 0, 0).render(contentWidth); const lines = this.tailLines !== undefined && wrapped.length > this.tailLines ? wrapped.slice(wrapped.length - this.tailLines) : wrapped; - return lines.map((line, index) => - index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, - ); + if (this.minLines !== undefined) { + while (lines.length < this.minLines) lines.push(''); + } + 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; } } export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; + private readonly markdownTheme = createMarkdownTheme(); private result: ToolResultBlockData | undefined; - private colors: ColorPalette; private ui: TUI | undefined; - private markdownTheme: MarkdownTheme | undefined; private planPath: string | undefined; /** * Fallback plan body used when the LLM uses plan-file mode and @@ -526,8 +555,19 @@ export class ToolCallComponent extends Container { */ private subagentText = ''; private subagentThinkingText = ''; + /** Tracks whether the child agent's latest streamed delta was text or thinking, + * so the active window can follow whichever is currently live. */ + private lastSubagentStreamKind: SubagentTextKind = 'text'; // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -547,6 +587,7 @@ export class ToolCallComponent extends Container { private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined; private subagentStartedAtMs: number | undefined; private subagentEndedAtMs: number | undefined; + private subagentSpinnerFrame = 0; // ── Live progress lines ────────────────────────────────────────── // @@ -558,6 +599,14 @@ export class ToolCallComponent extends Container { // authoritative final state. private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + private liveOutput = ''; + + /** + * Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running + * for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands. + */ + private detachHintTimer: ReturnType<typeof setTimeout> | undefined; + private detachHintVisible = false; /** * Registered by a group container (`AgentGroupComponent` or @@ -572,17 +621,13 @@ export class ToolCallComponent extends Container { constructor( toolCall: ToolCallBlockData, result: ToolResultBlockData | undefined, - colors: ColorPalette, ui?: TUI, - markdownTheme?: MarkdownTheme, private readonly workspaceDir?: string, ) { super(); this.toolCall = toolCall; this.result = result; - this.colors = colors; this.ui = ui; - this.markdownTheme = markdownTheme; this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -591,10 +636,60 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); + 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(); } setExpanded(expanded: boolean): void { @@ -614,6 +709,9 @@ export class ToolCallComponent extends Container { // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; + this.liveOutput = ''; + this.detachHintVisible = false; + this.stopDetachHintTimer(); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -656,9 +754,23 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendLiveOutput(text: string): void { + if (this.result !== undefined || text.length === 0) return; + this.liveOutput += text; + if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { + this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, + )}`; + } + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); + this.stopDetachHintTimer(); } /** @@ -760,14 +872,11 @@ export class ToolCallComponent extends Container { // 'spawning' and keep showing `Initializing...`. // Intermediate states without a result still use `subagentPhase`. // `backgrounded` has no result because background agents do not enter the - // transcript. - const derivedPhase: ToolCallSubagentSnapshot['phase'] = - this.backgroundTaskTerminalPhase ?? - (this.result !== undefined - ? this.result.is_error - ? 'failed' - : 'done' - : this.subagentPhase); + // transcript — but a foreground subagent detached via Ctrl+B keeps + // `subagentPhase === 'backgrounded'` even after its ToolResult lands, so + // the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse + // the standalone derivation so both paths agree. + const derivedPhase = this.getDerivedSubagentPhase(); const errorText = this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined); return { @@ -777,6 +886,7 @@ export class ToolCallComponent extends Container { agentName: this.subagentAgentName, phase: derivedPhase, toolCount: finished, + elapsedSeconds: this.getSubagentElapsedSeconds(), tokens, isError: derivedPhase === 'failed', errorText, @@ -880,6 +990,46 @@ export class ToolCallComponent extends Container { this.streamingProgressTimer = undefined; } + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + return; + } + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + }, DETACH_HINT_DELAY_MS); + } + + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; + } + + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + private syncSubagentElapsedTimer(): void { const phase = this.getDerivedSubagentPhase(); const shouldTick = @@ -897,10 +1047,14 @@ export class ToolCallComponent extends Container { this.stopSubagentElapsedTimer(); return; } + // Drives both the braille spinner in the header and the elapsed-seconds + // refresh. Only the header text changes on a tick, so we avoid rebuilding + // the body (which would defeat the per-component render caches). + this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; this.headerText.setText(this.buildHeader()); - this.invalidate(); + this.notifySnapshotChange(); this.ui?.requestRender(); - }, SUBAGENT_ELAPSED_INTERVAL_MS); + }, BRAILLE_SPINNER_INTERVAL_MS); } private stopSubagentElapsedTimer(): void { @@ -1066,6 +1220,22 @@ export class ToolCallComponent extends Container { this.notifySnapshotChange(); } + /** + * Mark a foreground subagent as detached-to-background. Called when a + * `background.task.started` event arrives for this agent (i.e. the user + * pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of + * flipping to `✓ Completed` when the spawn-success ToolResult lands. + */ + markBackgrounded(): void { + if (this.detachedFromForeground) return; + this.detachedFromForeground = true; + this.subagentPhase = 'backgrounded'; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Subagent id for the backing AgentTool call, used by routing to find a * tool call's backing subagent when reconciling background task lifecycle @@ -1104,6 +1274,7 @@ export class ToolCallComponent extends Container { } appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + this.lastSubagentStreamKind = kind; if (kind === 'thinking') { this.subagentThinkingText += text; } else { @@ -1176,6 +1347,24 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendSubToolLiveOutput(id: string, text: string): void { + if (text.length === 0) return; + const activity = this.subToolActivities.get(id); + const ongoing = this.ongoingSubCalls.get(id); + if (activity === undefined && ongoing === undefined) return; + const name = activity?.name ?? ongoing?.name ?? 'Tool'; + const args = activity?.args ?? ongoing?.args ?? {}; + const existingOutput = activity?.output ?? ''; + let output = existingOutput + text; + if (output.length > MAX_LIVE_OUTPUT_CHARS) { + output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + } + this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + finishSubToolCall(result: { tool_call_id: string; output: string; @@ -1208,24 +1397,24 @@ export class ToolCallComponent extends Container { } private buildHeader(): string { - const { toolCall, result, colors } = this; + const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; const isTruncated = toolCall.truncated === true && !isFinished; let bullet: string; if (isFinished) { - bullet = isError ? chalk.hex(colors.error)('✗ ') : chalk.hex(colors.success)(STATUS_BULLET); + bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { - bullet = chalk.hex(colors.error)('✗ '); + bullet = currentTheme.fg('error', '✗ '); } else { // Solid bullet for in-flight tools — the previous marker ↔ blank // toggle caused visible flicker on every re-render. - bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + bullet = currentTheme.fg('text', STATUS_BULLET); } if (toolCall.name === 'ExitPlanMode') { - const label = chalk.hex(colors.primary).bold('Current plan'); + const label = currentTheme.boldFg('primary', 'Current plan'); if (!isFinished || result === undefined || result.is_error === true) { return label; } @@ -1235,7 +1424,7 @@ export class ToolCallComponent extends Container { outcome.chosen !== undefined && outcome.chosen.length > 0 ? `Approved: ${outcome.chosen}` : 'Approved'; - return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; + return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; } return label; } @@ -1251,14 +1440,27 @@ export class ToolCallComponent extends Container { : isBackgroundAsk ? 'Starting background question' : 'Waiting for your input'; - const tone = isError ? chalk.hex(colors.error) : chalk.hex(colors.primary); - return `${bullet}${tone.bold(label)}`; + const tone = isError ? 'error' : 'primary'; + return `${bullet}${currentTheme.boldFg(tone, label)}`; + } + + if (toolCall.name === 'Bash') { + // The command itself is rendered in the body (with a `$` prompt), so the + // header only names the action — repeating the command in parentheses + // would duplicate the body. Wording mirrors the other label-only headers + // (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; } const goalHeader = buildGoalToolHeader({ toolCall, result, - colors, bullet, chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', }); @@ -1272,13 +1474,13 @@ export class ToolCallComponent extends Container { const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated - ? chalk.hex(colors.error)(verb) + ? currentTheme.fg('error', verb) : verb; const toolLabel = decoded !== null - ? `${chalk.hex(colors.primary).bold(decoded.toolName)}${chalk.dim(` · MCP/${decoded.serverName}`)}` - : chalk.hex(colors.primary).bold(toolCall.name); - const argStr = keyArg ? chalk.dim(` (${keyArg})`) : ''; + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); + const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; @@ -1289,8 +1491,8 @@ export class ToolCallComponent extends Container { if (provider === undefined) return ''; const text = provider(this.toolCall, result); if (text.length === 0) return ''; - const tone = result.is_error ? chalk.hex(this.colors.error) : chalk.dim; - return tone(` · ${text}`); + if (result.is_error) return currentTheme.fg('error', ` · ${text}`); + return currentTheme.dim(` · ${text}`); } private rebuildContent(): void { @@ -1298,6 +1500,8 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1309,6 +1513,8 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildDetachHintBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1334,15 +1540,33 @@ export class ToolCallComponent extends Container { PROGRESS_URL_RE.lastIndex = 0; const styled = PROGRESS_URL_RE.test(raw) ? raw.replace(PROGRESS_URL_RE, (url) => { - const visible = chalk.hex(this.colors.warning).underline(url); + const visible = currentTheme.underlineFg('warning', url); return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; }) - : chalk.dim(raw); + : currentTheme.dim(raw); PROGRESS_URL_RE.lastIndex = 0; this.addChild(new Text(styled, 2, 0)); } } + private buildLiveOutputBlock(): void { + if (this.result !== undefined) return; + if (this.liveOutput.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + result: { + tool_call_id: this.toolCall.id, + output: this.liveOutput, + is_error: false, + }, + expanded: this.expanded, + resultPreviewLines: RESULT_PREVIEW_LINES, + tailOutput: true, + expandHint: false, + }), + ); + } + private buildSubagentBlock(): void { if ( this.subagentAgentId === undefined && @@ -1360,19 +1584,18 @@ export class ToolCallComponent extends Container { return; } - const dim = chalk.dim; const phaseChip = this.formatPhaseChip(); const headerLabel = this.subagentAgentName !== undefined ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` : `subagent (${this.formatAgentId()})`; - this.addChild(new Text(` ${dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); if (this.hiddenSubCallCount > 0) { const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; this.addChild( new Text( - dim.italic(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`), + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`)), 0, 0, ), @@ -1381,26 +1604,26 @@ export class ToolCallComponent extends Container { for (const sub of this.finishedSubCalls) { const mark = sub.isError - ? chalk.hex(this.colors.error)('✗') - : chalk.hex(this.colors.success)('•'); + ? currentTheme.fg('error', '✗') + : currentTheme.fg('success', '•'); const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(sub.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', sub.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); } for (const [id, call] of this.ongoingSubCalls) { const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(call.name); - const argCol = keyArg ? dim(` (${keyArg})`) : ''; + const nameCol = currentTheme.fg('primary', call.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; void id; - this.addChild(new Text(` ${dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); } if (this.subagentText.length > 0) { const tailLines = this.subagentText.split('\n').slice(-3); for (const line of tailLines) { - this.addChild(new Text(` ${dim(line)}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim(line)}`, 0, 0)); } } @@ -1408,7 +1631,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); for (const line of summaryLines) { - this.addChild(new Text(` ${dim('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('└')} ${line}`, 0, 0)); } } @@ -1416,7 +1639,7 @@ export class ToolCallComponent extends Container { if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { const errLines = this.subagentError.split('\n'); for (const line of errLines) { - this.addChild(new Text(` ${chalk.hex(this.colors.error)('└')} ${line}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.fg('error', '└')} ${line}`, 0, 0)); } } } @@ -1432,7 +1655,6 @@ export class ToolCallComponent extends Container { */ private formatPhaseChip(): string { if (this.subagentPhase === undefined) return ''; - const dim = chalk.dim; const parts: string[] = []; switch (this.subagentPhase) { case 'queued': @@ -1445,7 +1667,7 @@ export class ToolCallComponent extends Container { parts.push('↻ running'); break; case 'done': { - parts.push(chalk.hex(this.colors.success)('✓ done')); + parts.push(currentTheme.fg('success', '✓ done')); const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); const tokens = @@ -1455,13 +1677,13 @@ export class ToolCallComponent extends Container { break; } case 'failed': - parts.push(chalk.hex(this.colors.error)('✗ failed')); + parts.push(currentTheme.fg('error', '✗ failed')); break; case 'backgrounded': parts.push('◐ backgrounded'); break; } - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; } private formatAgentId(): string { @@ -1490,49 +1712,55 @@ export class ToolCallComponent extends Container { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; return this.subagentPhase; } private buildSingleSubagentHeader(): string { const phase = this.getDerivedSubagentPhase(); - const isFailed = phase === 'failed'; const isDone = phase === 'done'; - const bullet = isFailed - ? chalk.hex(this.colors.error)('✗ ') - : isDone - ? chalk.hex(this.colors.success)(STATUS_BULLET) - : chalk.hex(this.colors.roleAssistant)(STATUS_BULLET); + const marker = this.buildSingleSubagentMarker(phase); const labelText = formatSubagentLabel(this.subagentAgentName); - const label = chalk.hex(this.colors.primary).bold(labelText); + const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); - const description = str(this.toolCall.args['description']); + const rawDescription = str(this.toolCall.args['description']); + const description = + rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH + ? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…` + : rawDescription; const descriptionPlain = description.length > 0 ? ` (${description})` : ''; - const descriptionText = descriptionPlain.length > 0 ? chalk.dim(descriptionPlain) : ''; + const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - const success = chalk.hex(this.colors.success); - return `${bullet}${success.bold(labelText)} ${success(`Completed${descriptionPlain}${statsText}`)}`; + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } - const stats = chalk.dim(statsText); - return `${bullet}${label} ${status}${descriptionText}${stats}`; + const stats = currentTheme.dim(statsText); + return `${marker}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { switch (phase) { case 'done': - return chalk.hex(this.colors.success)('Completed'); + return currentTheme.fg('success', 'Completed'); case 'failed': - return chalk.hex(this.colors.error)('Failed'); + return currentTheme.fg('error', 'Failed'); case 'running': - return chalk.hex(this.colors.primary)('Running'); + return currentTheme.fg('primary', 'Running'); case 'backgrounded': return 'Backgrounded'; case 'queued': - return chalk.hex(this.colors.primary)('Queued'); + return currentTheme.fg('primary', 'Queued'); case 'spawning': case undefined: - return chalk.hex(this.colors.primary)('Starting'); + return currentTheme.fg('primary', 'Starting'); } } @@ -1558,93 +1786,133 @@ export class ToolCallComponent extends Container { return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); } - private buildSingleSubagentBlock(): void { - for (const activity of this.getRecentSubToolActivities()) { - const mark = - activity.phase === 'failed' - ? chalk.hex(this.colors.error)('✗') - : activity.phase === 'done' - ? chalk.hex(this.colors.success)('•') - : chalk.hex(this.colors.text)('•'); - const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; - this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); - this.addSubToolOutputPreview(activity); - } - - if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { - const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); - if (errorLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.error)('└')} `, - ' ', - chalk.hex(this.colors.error)(errorLine), - ), - ); - } - return; - } - - const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); - if ( - this.getDerivedSubagentPhase() !== 'done' && - this.subagentThinkingText.trim().length > 0 - ) { - // Scroll thinking within a fixed two-row window (width-aware), matching - // the main agent's live thinking instead of growing without bound. - this.addChild( - new PrefixedWrappedLine( - ` ${chalk.dim('◌')} `, - ' ', - chalk.dim(this.subagentThinkingText.trimEnd()), - THINKING_PREVIEW_LINES, - ), - ); - } - if (outputLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${chalk.hex(this.colors.text)('└')} `, - ' ', - chalk.hex(this.colors.text)(outputLine), - ), - ); - } + private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string { + if (phase === 'failed') return currentTheme.fg('error', '✗ '); + if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET); + if (phase === 'backgrounded') return currentTheme.dim('◐ '); + // Active (queued / spawning / running): a braille spinner reads as alive + // where a static bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + return currentTheme.fg('primary', `${frame} `); } - private addSubToolOutputPreview(activity: SubToolActivity): void { - if (activity.phase === 'ongoing') return; - const output = activity.output; - if (output === undefined || output.trim().length === 0) return; - // Mirror the main agent: Bash and any tool without a dedicated renderer - // (every MCP tool included) get a truncated output preview. Recognized - // tools keep their compact activity row only. - if (activity.name !== 'Bash' && !isGenericToolResult(activity.name)) return; - this.addChild( - new TruncatedOutputComponent(output, { - // Subagent output is always fixed-truncated; it does not take part in - // the ctrl+o expand toggle, so don't advertise it either. - expanded: false, - expandHint: false, - isError: activity.phase === 'failed', - colors: this.colors, - maxLines: RESULT_PREVIEW_LINES, - indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, - }), + private buildSingleSubagentBlock(): void { + const phase = this.getDerivedSubagentPhase(); + + // Every state shares the same skeleton — header, a one-line tool summary, + // and a fixed two-row content window — so the card height is identical + // while running and after it finishes (no end-of-run shrink). + this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + + if (phase === 'failed') { + this.addChild(this.buildSingleSubagentResultWindow('error')); + return; + } + if (phase === 'done' || phase === 'backgrounded') { + this.addChild(this.buildSingleSubagentResultWindow('output')); + return; + } + this.addChild(this.buildSingleSubagentActiveWindow()); + } + + /** Most-recently-started sub-tool, preferring one that is still running. */ + private getCurrentSubToolActivity(): SubToolActivity | undefined { + let latestOngoing: SubToolActivity | undefined; + let latest: SubToolActivity | undefined; + for (const activity of this.subToolActivities.values()) { + if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity; + if ( + activity.phase === 'ongoing' && + (latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq) + ) { + latestOngoing = activity; + } + } + return latestOngoing ?? latest; + } + + /** + * The single live stream shown in the active window. A running sub-tool with + * previewable output (Bash or any tool without a dedicated renderer) wins; + * otherwise the most-recently-updated of the child agent's text / thinking. + */ + private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined { + const current = this.getCurrentSubToolActivity(); + if ( + current?.phase === 'ongoing' && + current.output !== undefined && + current.output.trim().length > 0 && + (current.name === 'Bash' || isGenericToolResult(current.name)) + ) { + return { text: current.output, tone: 'text' }; + } + if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + if (this.subagentText.trim().length > 0) { + return { text: this.subagentText, tone: 'text' }; + } + if (this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + return undefined; + } + + private buildSingleSubagentSummaryLine(): string { + const toolCount = this.subToolActivities.size; + const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; + const current = this.getCurrentSubToolActivity(); + if (current === undefined) { + return currentTheme.dim(` · ${countLabel}`); + } + const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', current.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + const mark = + current.phase === 'failed' + ? currentTheme.fg('error', ' ✗') + : current.phase === 'done' + ? currentTheme.fg('success', ' ✓') + : ''; + return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + } + + private buildSingleSubagentActiveWindow(): Component { + const gutter = currentTheme.dim('│'); + const content = this.getActiveSubagentContent(); + // Keep both tones muted: a bright `fg('text')` here flashed white whenever + // the window flipped between thinking and a brief text/tool-output segment. + const styled = + content === undefined + ? currentTheme.dim('…') + : content.tone === 'thinking' + ? currentTheme.dim(content.text) + : currentTheme.fg('textDim', content.text); + // Always exactly two rows (padded when short) so the live window matches + // the finished card's height. + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, ); } - private getRecentSubToolActivities(): SubToolActivity[] { - return [...this.subToolActivities.values()] - .toSorted((a, b) => a.orderSeq - b.orderSeq) - .slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS); - } - - private formatSubToolActivity(verb: string, activity: SubToolActivity): string { - const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = chalk.hex(this.colors.primary)(activity.name); - const argCol = keyArg ? chalk.dim(` (${keyArg})`) : ''; - return `${verb} ${nameCol}${argCol}`; + private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component { + const gutter = currentTheme.dim('│'); + const source = kind === 'error' ? this.subagentError : this.subagentText; + const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n'); + const styled = + kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text); + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); } private buildCallPreview(): void { @@ -1656,7 +1924,7 @@ export class ToolCallComponent extends Container { if (this.result === undefined && this.toolCall.truncated === true) { this.addChild( new Text( - chalk.dim('Tool call arguments truncated by max_tokens — call never executed.'), + currentTheme.dim('Tool call arguments truncated by max_tokens — call never executed.'), 2, 0, ), @@ -1667,7 +1935,14 @@ export class ToolCallComponent extends Container { this.buildStreamingPreview(this.toolCall.streamingArguments); return; } - const shouldCap = this.result !== undefined && !this.expanded; + // Cap Edit's diff as soon as args finalize, not only when the result + // lands — mirroring Write's writeShouldCap below. Otherwise the render + // tick between finalized args (streamingArguments cleared by the + // `tool.call.started` payload) and the result draws the full diff, then + // snaps back to the cap: a height collapse that triggers pi-tui's full + // redraw and wipes scrollback. Streaming frames (streamingArguments set) + // still take buildStreamingPreview above and never reach here. + const shouldCap = !this.expanded; if (name === 'Write') { const content = str(this.toolCall.args['content']); if (content.length === 0) return; @@ -1682,13 +1957,13 @@ export class ToolCallComponent extends Container { const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { - const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } if (writeShouldCap && remaining > 0) { this.addChild( new Text( - chalk.dim( + currentTheme.dim( `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, ), 2, @@ -1701,13 +1976,31 @@ export class ToolCallComponent extends Container { const newStr = str(this.toolCall.args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return; const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); - const lines = renderDiffLinesClustered(oldStr, newStr, filePath, this.colors, { + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); for (const line of lines) { this.addChild(new Text(line, 2, 0)); } + } else if (name === 'Bash') { + // Surface the command in the body across the whole lifecycle — while + // streaming, running, and after the result lands. Keeping the collapsed + // command preview here (instead of yielding to the result renderer once + // the result lands) avoids a height collapse when a multi-line command + // finishes with short output: the command block stays put and only the + // live-output tail swaps for the result. Owned solely by buildCallPreview + // so the command never renders twice; shellExecutionResultRenderer + // renders the result only. + const command = str(this.toolCall.args['command']); + if (command.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command, + showCommand: true, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + }), + ); } } @@ -1743,7 +2036,7 @@ export class ToolCallComponent extends Container { allLines.length > maxLines ? allLines.length - maxLines + i : i; - const lineNum = chalk.dim(String(originalLineNumber + 1).padStart(4) + ' '); + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } return; @@ -1761,7 +2054,7 @@ export class ToolCallComponent extends Container { const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( elapsedSeconds, )} elapsed`; - this.addChild(new Text(chalk.dim(progress), 2, 0)); + this.addChild(new Text(currentTheme.dim(progress), 2, 0)); return; } if (name === 'Bash') { @@ -1770,9 +2063,8 @@ export class ToolCallComponent extends Container { this.addChild( new ShellExecutionComponent({ command: cmd, - colors: this.colors, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } @@ -1783,20 +2075,15 @@ export class ToolCallComponent extends Container { private buildPlanPreview(): void { // Priority: inline `args.plan`, approved plan parsed from result, then // asynchronously injected currentPlan used while approval is in flight. - // Once a plan is found, PlanBoxComponent renders it. Without markdownTheme - // (unit tests), fall back to indented dim text so it remains visible. + // Once a plan is found, PlanBoxComponent renders it. const plan = this.resolvePlanForPreview(); if (plan.length === 0) return; const path = this.resolvePlanPath(); - if (this.markdownTheme !== undefined) { - this.addChild( - new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { - status: this.resolvePlanBoxStatus(), - }), - ); - } else { - this.addChild(new Text(chalk.dim(plan), 2, 0)); - } + this.addChild( + new PlanBoxComponent(plan, this.markdownTheme, currentTheme.color('success'), path, { + status: this.resolvePlanBoxStatus(), + }), + ); } private resolvePlanForPreview(): string { @@ -1825,7 +2112,7 @@ export class ToolCallComponent extends Container { if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; const outcome = interpretExitPlanModeOutcome(result.output); if (outcome.kind !== 'rejected') return undefined; - return { label: 'Rejected', colorHex: this.colors.error }; + return { label: 'Rejected', colorHex: currentTheme.color('error') }; } private buildContent(): void { @@ -1843,10 +2130,14 @@ export class ToolCallComponent extends Container { return; } - // Outputs that start with a `<system…>` tag are harness-injected - // reminders piggy-backing on a tool result. They are noise for the - // user, so suppress the body while keeping the header chip intact. - if (result.output.trimStart().startsWith('<system')) { + // Outputs that start with a `<system-reminder>` tag are harness-injected + // reminders piggy-backing on a tool result (e.g. a finalize hook rewrote + // the output). They are noise for the user, so suppress the body while + // keeping the header chip intact. Match the full reminder tag only: tool + // metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with a literal `<system>` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('<system-reminder>')) { return; } @@ -1858,7 +2149,7 @@ export class ToolCallComponent extends Container { if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { const trimmed = outcome.feedback.trim(); if (trimmed.length > 0) { - const labelTone = chalk.hex(this.colors.warning).bold; + const labelTone = (text: string) => currentTheme.boldFg('warning', text); this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); for (const line of trimmed.split('\n')) { this.addChild(new Text(line, 4, 0)); @@ -1891,7 +2182,6 @@ export class ToolCallComponent extends Container { const renderer = pickResultRenderer(this.toolCall.name); const components = renderer(this.toolCall, result, { expanded: this.expanded, - colors: this.colors, }); for (const component of components) { this.addChild(component); @@ -1900,23 +2190,23 @@ export class ToolCallComponent extends Container { private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { const summary = agentSwarmResultSummaryFromOutput(result.output); - const dim = chalk.hex(this.colors.textDim); + const dim = (s: string): string => currentTheme.fg('textDim', s); const segments: string[] = []; if (summary.completed > 0) { - segments.push(chalk.hex(this.colors.success)( - `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`, - )); + segments.push( + currentTheme.fg('success', `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`), + ); } if (summary.failed > 0) { - segments.push(chalk.hex(this.colors.error)( - `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`, - )); + segments.push( + currentTheme.fg('error', `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`), + ); } if (summary.aborted > 0) { - segments.push(chalk.hex(this.colors.warning)( - `${ABORTED_MARK} ${String(summary.aborted)} aborted`, - )); + segments.push( + currentTheme.fg('warning', `${ABORTED_MARK} ${String(summary.aborted)} aborted`), + ); } if (segments.length > 0) { @@ -1925,17 +2215,13 @@ export class ToolCallComponent extends Container { } const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); - const color = isAborted - ? this.colors.warning - : result.is_error === true - ? this.colors.error - : this.colors.success; + const colorToken = isAborted ? 'warning' : result.is_error === true ? 'error' : 'success'; const label = isAborted ? `${ABORTED_MARK} Aborted.` : result.is_error === true ? `${FAILURE_MARK.trimEnd()} Failed.` : `${SUCCESS_MARK.trimEnd()} Completed.`; - this.addChild(new Text(`${dim('Agent swarm: ')}${chalk.hex(color)(label)}`, 2, 0)); + this.addChild(new Text(`${dim('Agent swarm: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); } /** @@ -1952,9 +2238,7 @@ export class ToolCallComponent extends Container { } if (typeof parsed !== 'object' || parsed === null) return false; - const colors = this.colors; - const dim = chalk.dim; - const accent = chalk.hex(colors.primary); + const accent = (text: string) => currentTheme.fg('primary', text); const answers = (parsed as { answers?: unknown }).answers; const note = (parsed as { note?: unknown }).note; @@ -1965,13 +2249,13 @@ export class ToolCallComponent extends Container { if (!hasAnswers) { const noteText = typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; - this.addChild(new Text(dim(` ${noteText}`), 0, 0)); + this.addChild(new Text(currentTheme.dim(` ${noteText}`), 0, 0)); return true; } for (const [question, answer] of Object.entries(answers as Record<string, unknown>)) { const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); - this.addChild(new Text(` ${dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); } return true; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index bcf1e72bf..1b38fd278 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,8 +1,7 @@ -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { formatTokenCount } from '#/utils/usage/usage-format'; @@ -50,24 +49,23 @@ export const goalSummary: ResultRenderer = (toolCall, result, ctx) => { export function buildGoalToolHeader(options: { readonly toolCall: ToolCallBlockData; readonly result: ToolResultBlockData | undefined; - readonly colors: ColorPalette; readonly bullet: string; readonly chip: string; }): string | undefined { - const { toolCall, result, colors, bullet, chip } = options; + const { toolCall, result, bullet, chip } = options; if (!isGoalToolName(toolCall.name)) return undefined; - const tone = result?.is_error === true ? colors.error : colors.primary; - const label = chalk.hex(tone).bold(goalToolLabel(toolCall.name, result, toolCall.args)); + const tone = result?.is_error === true ? 'error' : 'primary'; + const label = currentTheme.boldFg(tone, goalToolLabel(toolCall.name, result, toolCall.args)); const marker = result !== undefined && result.is_error !== true - ? chalk.hex(colors.primary)(STATUS_BULLET) + ? currentTheme.fg('primary', STATUS_BULLET) : bullet; const arg = toolCall.name === 'UpdateGoal' ? undefined : formatGoalToolArgument(toolCall.name, toolCall.args); - const argText = arg === undefined ? '' : chalk.hex(colors.textDim)(` (${arg})`); + const argText = arg === undefined ? '' : currentTheme.dimFg('textDim', ` (${arg})`); return `${marker}${label}${argText}${chip}`; } @@ -95,13 +93,13 @@ export function goalStatusChip(output: string): string { function renderGoalSnapshot( toolCall: ToolCallBlockData, result: ToolResultBlockData, - ctx: Parameters<ResultRenderer>[2], + _ctx: Parameters<ResultRenderer>[2], ) { const goal = parseGoalToolOutput(result.output); - if (goal === undefined) return renderTruncated(toolCall, result, ctx); + if (goal === undefined) return renderTruncated(toolCall, result, _ctx); - const muted = chalk.hex(ctx.colors.textDim); - const value = chalk.hex(ctx.colors.text); + const muted = (s: string) => currentTheme.dimFg('textDim', s); + const value = (s: string) => currentTheme.fg('text', s); if (goal === null) return [new Text(muted(' No current goal.'), 0, 0)]; const lines = [ diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts index fd753cd27..b798cc8e5 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -13,8 +13,8 @@ * message. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ChipProvider } from './chip'; @@ -27,11 +27,9 @@ export interface ReadMediaSummary { mimeType?: string; bytes?: number; url?: string; - originalSize?: string; } const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; -const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; function bytesFromBase64(b64: string): number { @@ -55,7 +53,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { let mimeType: string | undefined; let bytes: number | undefined; let url: string | undefined; - let originalSize: string | undefined; let foundMedia = false; for (const raw of parsed) { @@ -64,15 +61,11 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { const type = part['type']; if (type === 'text' && typeof part['text'] === 'string') { - const text = part['text']; - const tag = PATH_TAG_RE.exec(text); + const tag = PATH_TAG_RE.exec(part['text']); if (tag) { kind = tag[1] as 'image' | 'video'; path = tag[2]; - continue; } - const size = ORIGINAL_SIZE_RE.exec(text); - if (size) originalSize = size[1]; continue; } @@ -103,7 +96,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { if (mimeType !== undefined) summary.mimeType = mimeType; if (bytes !== undefined) summary.bytes = bytes; if (url !== undefined) summary.url = url; - if (originalSize !== undefined) summary.originalSize = originalSize; return summary; } @@ -117,7 +109,6 @@ function metaSegments(summary: ReadMediaSummary): string[] { const segs: string[] = []; if (summary.mimeType !== undefined) segs.push(summary.mimeType); if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); - if (summary.originalSize !== undefined) segs.push(summary.originalSize); return segs; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index a3f929dcc..ac31cec8e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -10,8 +10,8 @@ * sees the actual error message, not a synthetic summary. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { renderTruncated } from './truncated'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 54706fc99..dc1066bec 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,7 +1,6 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; +import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; @@ -26,38 +25,57 @@ export function trimTrailingEmptyLines(lines: string[]): string[] { * JSON blobs) that would otherwise wrap to dozens of visual rows. */ export class TruncatedOutputComponent implements Component { - private readonly textComponent: Text; + private textComponent: Text; private readonly expanded: boolean; private readonly maxLines: number; private readonly indent: number; private readonly expandHint: boolean; + private readonly tail: boolean; constructor( output: string, options: { expanded: boolean; isError: boolean | undefined; - colors: ColorPalette; maxLines?: number; indent?: number; // When false, the truncation footer omits the "ctrl+o to expand" promise // (for contexts whose output is fixed-truncated and never expands). expandHint?: boolean; + // When true, collapsed rendering keeps the latest visual rows instead of + // the first rows. This is useful for live output from a running command. + tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; }, ) { this.expanded = options.expanded; this.maxLines = options.maxLines ?? PREVIEW_LINES; this.indent = options.indent ?? DEFAULT_INDENT; this.expandHint = options.expandHint ?? true; - const tint = options.isError ? chalk.hex(options.colors.error) : chalk.dim; + this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); - this.textComponent = new Text(tint(cleaned), this.indent, 0); + const successColor = options.color ?? 'textDim'; + this.textComponent = new Text( + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), + this.indent, + 0, + ); } invalidate(): void { + // Text component caches wrapped lines; invalidate on terminal resize. this.textComponent.invalidate(); } + private renderHint(width: number, hint: string): string { + const indentWidth = Math.min(this.indent, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); + } + render(width: number): string[] { const contentLines = this.textComponent.render(width); @@ -65,12 +83,20 @@ export class TruncatedOutputComponent implements Component { return contentLines; } - const shown = contentLines.slice(0, this.maxLines); const remaining = contentLines.length - this.maxLines; + if (this.tail) { + const shown = contentLines.slice(contentLines.length - this.maxLines); + return [ + this.renderHint(width, `... (${String(remaining)} earlier lines)`), + ...shown, + ]; + } + + const shown = contentLines.slice(0, this.maxLines); const hint = this.expandHint ? `... (${String(remaining)} more lines, ctrl+o to expand)` : `... (${String(remaining)} more lines)`; - return [...shown, ' '.repeat(this.indent) + chalk.dim(hint)]; + return [...shown, this.renderHint(width, hint)]; } } @@ -80,7 +106,6 @@ export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, isError: result.is_error ?? false, - colors: ctx.colors, }), ]; }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 462b53a44..da3dc3a5a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,12 +1,10 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; -import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; export interface RendererContext { readonly expanded: boolean; - readonly colors: ColorPalette; } export type ResultRenderer = ( diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 0e4401a11..195860bc3 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -4,10 +4,9 @@ * the pattern stays consistent across command-triggered panels. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; import { formatTokenCount, @@ -15,11 +14,12 @@ import { renderProgressBar, safeUsageRatio, } from '#/utils/usage/usage-format'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; -const MIN_INTERIOR_WIDTH = 20; +const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; type Colorize = (text: string) => string; @@ -30,13 +30,22 @@ export interface ManagedUsageRow { readonly resetHint?: string; } +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { - readonly colors: ColorPalette; readonly sessionUsage?: SessionUsage; readonly sessionUsageError?: string; readonly contextUsage: number; @@ -47,7 +56,6 @@ export interface UsageReportOptions { } export interface ManagedUsageReportLineOptions { - readonly colors: ColorPalette; readonly managedUsage?: ManagedUsageReport; readonly managedUsageError?: string; } @@ -108,7 +116,6 @@ function buildManagedUsageSection( value: Colorize, muted: Colorize, errorStyle: Colorize, - severityHex: (sev: 'ok' | 'warn' | 'danger') => string, ): string[] { if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; if (usage === undefined) return []; @@ -124,12 +131,13 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); const label = row.label.padEnd(labelWidth, ' '); const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); @@ -137,14 +145,96 @@ function buildManagedUsageSection( return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); return buildManagedUsageSection( options.managedUsage, @@ -153,18 +243,14 @@ export function buildManagedUsageReportLines(options: ManagedUsageReportLineOpti value, muted, errorStyle, - severityHex, ); } export function buildUsageReportLines(options: UsageReportOptions): string[] { - const colors = options.colors; - const accent = chalk.hex(colors.primary).bold; - const value = chalk.hex(colors.text); - const muted = chalk.hex(colors.textDim); - const errorStyle = chalk.hex(colors.error); - const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => - sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + const accent = (text: string) => currentTheme.boldFg('primary', text); + const value = (text: string) => currentTheme.fg('text', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); const lines: string[] = [ accent('Session usage'), @@ -181,7 +267,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); const pct = `${(ratio * 100).toFixed(1)}%`; - const barColoured = chalk.hex(severityHex(ratioSeverity(ratio)))(bar); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); lines.push( @@ -195,7 +281,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { } const managedSection = buildManagedUsageReportLines({ - colors, managedUsage: options.managedUsage, managedUsageError: options.managedUsageError, }); @@ -204,36 +289,63 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } export class UsagePanelComponent implements Component { - constructor( - private readonly lines: readonly string[], - private readonly borderHex: string, - private readonly title: string = ' Usage ', - ) {} + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; - invalidate(): void {} + constructor( + private readonly buildLines: () => readonly string[], + private readonly borderToken: ColorToken, + private readonly title: string = ' Usage ', + ) { + this.lines = buildLines(); + } + + invalidate(): void { + // Report bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines (the data itself is captured). + this.lines = this.buildLines(); + } render(width: number): string[] { - const paint = (s: string): string => chalk.hex(this.borderHex)(s); - const indent = ' '.repeat(LEFT_MARGIN); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; - const availableInterior = Math.max( - MIN_INTERIOR_WIDTH, - width - LEFT_MARGIN - 2 - 2 * SIDE_PADDING, - ); + const paint = (s: string): string => currentTheme.fg(this.borderToken, s); + const availableInterior = safeWidth - BOX_OVERHEAD; + if (availableInterior < 1) { + return [ + truncateToWidth(this.title.trim(), safeWidth, '…'), + ...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')), + ]; + } + + const indent = ' '.repeat(LEFT_MARGIN); const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); const contentWidth = Math.max( - MIN_INTERIOR_WIDTH, - Math.min(availableInterior, longestLine, Math.max(longestLine, this.title.length)), + 1, + Math.min(availableInterior, Math.max(longestLine, visibleWidth(this.title))), ); const horzLen = contentWidth + 2 * SIDE_PADDING; + const title = truncateToWidth(this.title, horzLen, '…'); - const trailingDashLen = Math.max(0, horzLen - this.title.length); + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); const top = - indent + paint('╭') + paint(this.title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); + indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯'); const out: string[] = [top]; @@ -243,6 +355,6 @@ export class UsagePanelComponent implements Component { out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│')); } out.push(bottom); - return out; + return out.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 8617598a0..cec7fbd13 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -2,49 +2,68 @@ * Renders a user message in the transcript. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { - private color: string; - private textComponent: Text; + private text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, colors: ColorPalette, images?: ImageAttachment[]) { - this.color = colors.roleUser; - this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0); + 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, colors)) ?? []; + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; + } + + private markRenderDirty(): void { + this.renderCache = undefined; } invalidate(): void { - this.textComponent.invalidate(); + this.markRenderDirty(); for (const img of this.imageThumbnails) { img.invalidate?.(); } } render(width: number): string[] { - const bullet = chalk.hex(this.color).bold(USER_MESSAGE_BULLET); + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + 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, width - bulletWidth); + const contentWidth = Math.max(1, safeWidth - bulletWidth); const lines: string[] = []; // Spacer - for (const line of this.spacerComponent.render(width)) { + for (const line of this.spacerComponent.render(safeWidth)) { lines.push(line); } - // Text - const textLines = this.textComponent.render(contentWidth); + // 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++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); lines.push(prefix + textLines[i]); @@ -58,6 +77,22 @@ export class UserMessageComponent implements Component { } } - return lines; + 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 + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 2a1d8ea1d..22e6f3bc5 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,29 +1,38 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@moonshot-ai/pi-tui'; -import type { MoonLoader } from '../chrome/moon-loader'; +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; + readonly tip?: string; } export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + constructor(options: ActivityPaneOptions) { super(); + this.spinnerRef = options.spinner; - if (options.mode === 'waiting' || options.mode === 'tool') { - if (options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); - } - return; - } - - if (options.mode === 'composing' && options.spinner !== undefined) { + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { this.addChild(new Spacer(1)); + if (options.tip) { + options.spinner.setTip(` · Tip: ${options.tip}`); + } this.addChild(options.spinner); } } + + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); + } + return super.render(width); + } } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 990f6b59a..f32aa9321 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -1,14 +1,14 @@ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme } from '@moonshot-ai/pi-tui'; import { Markdown, Text, truncateToWidth, visibleWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '../../theme'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -28,7 +28,6 @@ interface BtwBodyRender { } export interface BtwPanelOptions { - readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; readonly canUseScrollKeys: () => boolean; readonly onPrompt: (prompt: string) => void; @@ -119,14 +118,14 @@ export class BtwPanelComponent implements Component { } private renderTopBorder(width: number, truncated: boolean): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const hint = truncated && this.options.canUseScrollKeys() ? 'Esc close · ↑↓ scroll ' : 'Esc close '; const title = - chalk.hex(this.options.colors.accent).bold(' BTW ') + + chalk.hex(currentTheme.palette.accent).bold(' BTW ') + paint('─ ') + - chalk.hex(this.options.colors.textMuted)(hint); + chalk.hex(currentTheme.palette.textMuted)(hint); const innerWidth = Math.max(1, width - 2); const clippedTitle = visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; @@ -141,7 +140,7 @@ export class BtwPanelComponent implements Component { lines.push(...this.renderTurn(turn, width)); } if (this.turns.length === 0) { - lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question...')); } lines.push(...this.renderTransientNotices(width)); return this.fitBodyLines(lines); @@ -150,7 +149,7 @@ export class BtwPanelComponent implements Component { private renderTransientNotices(width: number): string[] { const lines: string[] = []; for (const notice of this.transientNotices) { - lines.push(...new Text(chalk.hex(this.options.colors.textDim)(notice), 0, 0).render(width)); + lines.push(...new Text(chalk.hex(currentTheme.palette.textDim)(notice), 0, 0).render(width)); } return lines; } @@ -186,19 +185,19 @@ export class BtwPanelComponent implements Component { private collapsedBodyLimit(): number | undefined { const terminalRows = this.options.terminalRows(); if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; - const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 3)); return Math.max(1, maxPanelLines - 1); } private renderTurn(turn: BtwTurn, width: number): string[] { - const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + const prompt = chalk.hex(currentTheme.palette.accent)(`Q: ${turn.prompt}`); const lines = [...new Text(prompt, 0, 0).render(width)]; const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); } else if (thinking.length > 0) { - const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( width, ); const visibleThinking = @@ -207,17 +206,17 @@ export class BtwPanelComponent implements Component { : thinkingLines; lines.push(...visibleThinking); } else if (turn.error === undefined) { - lines.push(chalk.hex(this.options.colors.textDim)('Waiting for answer...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); } if (turn.error !== undefined) { - const error = chalk.hex(this.options.colors.error)(turn.error); + const error = chalk.hex(currentTheme.palette.error)(turn.error); lines.push(...new Text(error, 0, 0).render(width)); } return lines; } private renderBodyLine(line: string, width: number): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const paint = (s: string): string => chalk.hex(currentTheme.palette.border)(s); const contentWidth = Math.max(1, width - 4); const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 852216263..1a2b26d07 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,37 +1,67 @@ -import { Container, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; -import type { ColorPalette } from '../../theme/colors'; +import { currentTheme } from '#/tui/theme'; export interface QueuePaneOptions { readonly messages: readonly QueuedMessage[]; - readonly colors: ColorPalette; readonly isCompacting: boolean; readonly isStreaming: boolean; readonly canSteerImmediately: boolean; } +const ELLIPSIS = '…'; + export class QueuePaneComponent extends Container { + private readonly messages: readonly QueuedMessage[]; + private readonly hint: string | undefined; + constructor(options: QueuePaneOptions) { super(); - - const accent = chalk.hex(options.colors.accent); - const dim = chalk.hex(options.colors.textDim); - - for (const item of options.messages) { - this.addChild(new Text(accent(` ${SELECT_POINTER} ${item.text}`), 0, 0)); - } + this.messages = options.messages; if (options.messages.length > 0) { - const hint = + // 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'; - this.addChild(new Text(dim(hint), 0, 0)); + : 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} `; + 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) { + lines.push(dim(truncateToWidth(this.hint, width, ELLIPSIS))); + } + + return lines; + } } diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 54d4a8763..cd3329b55 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -17,7 +17,7 @@ import { getDataDir } from '#/utils/paths'; export const INVALID_TUI_CONFIG_MESSAGE = 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; -export const TuiThemeSchema = z.enum(['dark', 'light', 'auto']); +export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); @@ -32,6 +32,7 @@ export const UpgradePreferencesSchema = z.object({ export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + disable_paste_burst: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -52,6 +53,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + disablePasteBurst: z.boolean(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -73,6 +75,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -132,6 +135,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -149,7 +153,8 @@ export function renderTuiConfig(config: TuiConfig): string { # Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. -theme = "${config.theme}" # "auto" | "dark" | "light" +theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 000000000..eccb3f3fb --- /dev/null +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,3 @@ +// Timing constants for the clipboard-image hint controller. +export const FOCUS_DEBOUNCE_MS = 1_000; +export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 9e8d621f5..d72a62def 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -16,12 +16,15 @@ export { } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = "You're not signed in. Opening GitHub Issues for feedback…"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,6 +34,10 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users // at the `/export-debug-zip` workflow so they can share diagnostics with us. export function errorReportHintLine(): string { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e1..8c8f9807b 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -9,6 +9,10 @@ export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; export function isManagedUsageProvider( providerKey: string | undefined, diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 000000000..f9d0a7f27 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,50 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ + { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, + { text: '/tasks to check progress and status for background tasks', priority: 2 }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: 'Try /dance for a hidden Easter egg' }, + { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, + { + text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { 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[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Kimi to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { text: '/help: show commands' }, + { text: '/compact compresses context when it gets long', priority: 2 }, + { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, + { text: 'shift-tab to Plan mode to review the approach before Kimi edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 39af925fb..ae70c0cb8 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,12 +1,21 @@ -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; -import { refreshAllProviderModels } from '../utils/refresh-providers'; +import { + refreshAllProviderModels, + type RefreshProviderScope, + type RefreshResult, +} from '../utils/refresh-providers'; +import { thinkingEffortFromConfig } from '../utils/thinking-config'; import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + export interface AuthFlowHost { state: TUIState; session: Session | undefined; @@ -24,6 +33,7 @@ export interface AuthFlowHost { fetchSessions(): Promise<void>; updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise<void>; + refreshPluginCommands(session?: Session): Promise<void>; } export class AuthFlowController { @@ -42,7 +52,7 @@ export class AuthFlowController { this.host.setAppState({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -52,28 +62,31 @@ export class AuthFlowController { this.host.setStartupReady(); } - async activateModelAfterLogin(model: string, thinking?: boolean): Promise<void> { + async activateModelAfterLogin(model: string, effort?: string): Promise<void> { const { host } = this; - const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; if (host.session !== undefined) { await host.session.setModel(model); - if (level !== undefined) { - await host.session.setThinking(level); + if (effort !== undefined) { + await host.session.setThinking(effort); } return; } - const session = await host.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, - thinking: level, + thinking: effort, permission: host.options.startup.auto ? 'auto' : host.options.startup.yolo ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, - }); + }; + if (host.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...host.state.appState.additionalDirs]; + } + const session = await host.harness.createSession(options); await host.setSession(session); host.setAppState({ sessionId: session.id, @@ -84,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> { @@ -95,6 +109,7 @@ export class AuthFlowController { sessionTitle: null, }); await this.host.refreshSkillCommands(); + await this.host.refreshPluginCommands(); } async refreshConfigAfterLogin(): Promise<void> { @@ -110,16 +125,13 @@ export class AuthFlowController { return; } - await this.activateModelAfterLogin(defaultModel, config.defaultThinking); + await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); const appStatePatch: Partial<AppState> = { availableModels, availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, }; - if (config.defaultThinking !== undefined) { - appStatePatch.thinking = config.defaultThinking; - } host.setAppState(appStatePatch); } @@ -129,7 +141,7 @@ export class AuthFlowController { availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, model: '', - thinking: false, + thinkingEffort: 'off', maxContextTokens: 0, contextUsage: 0, contextTokens: 0, @@ -142,26 +154,28 @@ export class AuthFlowController { * config. Runs best-effort: individual provider failures are collected * and returned instead of thrown. */ - async refreshProviderModels(): Promise<{ - readonly changed: ReadonlyArray<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }>; - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; - }> { + async refreshProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('all'); + } + + async refreshOAuthProviderModels(): Promise<RefreshResult> { + return this.refreshProviderModelsWithScope('oauth'); + } + + private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> { const { host } = this; - const result = await refreshAllProviderModels({ - getConfig: () => host.harness.getConfig({ reload: true }), - removeProvider: (id) => host.harness.removeProvider(id), - setConfig: (patch) => host.harness.setConfig(patch), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); + const result = await refreshAllProviderModels( + { + getConfig: () => host.harness.getConfig({ reload: true }), + removeProvider: (id) => host.harness.removeProvider(id), + setConfig: (patch) => host.harness.setConfig(patch), + resolveOAuthToken: async (providerName, oauthRef) => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }, }, - }); + { scope }, + ); if (result.changed.length > 0) { await this.refreshAvailableModels(); } diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 7ebe79118..c29c76460 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -1,4 +1,4 @@ -import { Spacer } from '@earendil-works/pi-tui'; +import { Spacer } from '@moonshot-ai/pi-tui'; import type { Event, KimiHarness, @@ -10,6 +10,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; import type { TUIState } from '../tui-state'; const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; @@ -36,8 +37,7 @@ export class BtwPanelController { open(agentId: string, initialPrompt: string): void { let panel: BtwPanelComponent; panel = new BtwPanelComponent({ - colors: this.host.state.theme.colors, - markdownTheme: this.host.state.theme.markdownTheme, + markdownTheme: createMarkdownTheme(), canUseScrollKeys: () => this.host.state.editor.getText().length === 0, terminalRows: () => this.host.state.terminal.rows, onPrompt: (prompt) => { @@ -191,14 +191,7 @@ export class BtwPanelController { } private withInteractiveAgent<T>(agentId: string, fn: () => Promise<T>): Promise<T> { - const previousAgentId = this.host.harness.interactiveAgentId; - this.host.harness.interactiveAgentId = agentId; - try { - // SDK RPC methods snapshot interactiveAgentId before their first await. - return fn(); - } finally { - this.host.harness.interactiveAgentId = previousAgentId; - } + return this.host.harness.withInteractiveAgent(agentId, fn); } } @@ -209,5 +202,8 @@ function formatBtwTurnEnd(event: TurnEndedEvent): string { if (event.reason === 'cancelled') { return 'Interrupted by user'; } + if (event.reason === 'filtered') { + return 'Provider safety policy blocked the response.'; + } return `BTW turn ended with reason: ${event.reason}`; } diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts new file mode 100644 index 000000000..48dd1f8b8 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -0,0 +1,167 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +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'; + +export interface ClipboardImageHintHost { + readonly ui: TUI; + readonly footer: FooterComponent; + getModelSupportsImage(): boolean; + requestRender(): void; +} + +function getPasteImageShortcut(): string { + return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V'; +} + +export class ClipboardImageHintController { + private readonly host: ClipboardImageHintHost; + private disposeInputListener: (() => void) | undefined; + private debounceTimer: ReturnType<typeof setTimeout> | undefined; + private clearHintTimer: ReturnType<typeof setTimeout> | undefined; + 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; + } + + start(): void { + this.disposeInputListener = this.host.ui.addInputListener((data) => { + this.handleInput(data); + }); + void this.establishInitialBaseline(); + } + + stop(): void { + this.clearDebounceTimer(); + this.clearClearHintTimer(); + this.disposeInputListener?.(); + this.disposeInputListener = undefined; + + this.checkGeneration += 1; + this.clearOwnedHint(); + this.initialized = false; + this.armed = true; + } + + private handleInput(data: string): void { + if (data === TERMINAL_FOCUS_IN) { + this.focused = true; + this.scheduleCheck(); + return; + } + if (data === TERMINAL_FOCUS_OUT) { + this.focused = false; + this.clearDebounceTimer(); + return; + } + } + + private scheduleCheck(): void { + this.clearDebounceTimer(); + this.checkGeneration += 1; + const generation = this.checkGeneration; + this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS); + } + + private clearDebounceTimer(): void { + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + private clearClearHintTimer(): void { + if (this.clearHintTimer !== undefined) { + clearTimeout(this.clearHintTimer); + this.clearHintTimer = undefined; + } + } + + private clearOwnedHint(): void { + if (this.host.footer.getTransientHint() === this.lastHintText) { + this.host.footer.setTransientHint(null); + this.host.requestRender(); + } + 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; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + if (!this.focused) 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.clearHintTimer = setTimeout(() => { + this.clearOwnedHint(); + }, HINT_DISPLAY_MS); + } +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 847a6434d..383694c07 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,5 @@ -import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -7,13 +8,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'; @@ -21,25 +23,37 @@ export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; cancelInFlight: (() => void) | undefined; + /** + * The host's harness (KimiTUI always has one). Its `imageLimits` drives + * paste-time image compression; hosts without one fall back to the + * env/built-in default. + */ + harness?: KimiHarness | undefined; 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; updateQueueDisplay(): void; 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, @@ -59,6 +73,47 @@ export class EditorKeyboardController { host.updateEditorBorderHighlight(text); }; + // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode + // recalls everything. The filter is locked to the mode captured when the + // user first enters history browsing (see onHistoryDraftSave), so landing on + // a shell entry mid-browse doesn't switch the filter to shell-only. + let browseMode: 'prompt' | 'bash' | null = null; + editor.setHistoryFilter((entry: string) => { + const mode = browseMode ?? editor.inputMode; + return mode === 'bash' ? entry.startsWith('!') : true; + }); + + // Recalling a `!`-prefixed entry strips the marker and returns to bash + // mode; recalling a plain entry returns to prompt mode. The filter above + // guarantees bash mode only ever lands on `!` entries, so this never + // misfires on commands typed in bash mode. + editor.onRecall = (entry: string) => { + if (entry.startsWith('!')) { + editor.setInputMode('bash'); + return entry.slice(1); + } + editor.setInputMode('prompt'); + return undefined; + }; + + // Save/restore the input mode alongside pi-tui's history draft. Without + // this, recalling a shell entry and then pressing Down back to an empty + // draft would leave the editor stuck in bash mode, so the next typed + // message would be submitted as a shell command. Also locks the history + // filter (browseMode) for the duration of the browse session. + editor.onHistoryDraftSave = () => { + browseMode = editor.inputMode; + return editor.inputMode; + }; + editor.onHistoryDraftRestore = (state: unknown) => { + editor.setInputMode(state as 'prompt' | 'bash'); + browseMode = null; + }; + + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); + }; + editor.onCtrlC = () => { if (host.cancelInFlight !== undefined) { const cancel = host.cancelInFlight; @@ -70,6 +125,9 @@ export class EditorKeyboardController { if (host.state.appState.isCompacting) { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentCompaction(); return; } @@ -85,6 +143,9 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentStream(); return; } @@ -114,18 +175,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 = () => { @@ -139,6 +212,10 @@ export class EditorKeyboardController { host.handlePlanToggle(next); }; + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); + }; + editor.onOpenExternalEditor = () => { host.track('shortcut_editor'); void this.openExternalEditor(); @@ -149,21 +226,41 @@ export class EditorKeyboardController { host.toggleToolOutputExpansion(); }; + editor.onToggleTodoExpand = (): boolean => { + if (!host.state.todoPanel.hasOverflow()) return false; + // Disarm a pending double-press exit confirmation so expanding the + // todo list in between two Ctrl-C presses does not accidentally exit. + this.clearPendingExit(); + host.track('shortcut_todo_expand'); + host.toggleTodoPanelExpansion(); + return true; + }; + 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); @@ -175,12 +272,19 @@ export class EditorKeyboardController { host.state.ui.requestRender(); }; - editor.onUndo = () => { - host.track('undo'); + 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; + } + host.track('shortcut_background_task'); + host.detachCurrentForegroundTask(); + return true; }; - editor.onInsertNewline = () => { - host.track('shortcut_newline'); + editor.onUndo = () => { + host.track('undo'); }; editor.onTextPaste = () => { @@ -192,7 +296,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; @@ -212,6 +323,27 @@ export class EditorKeyboardController { this.pendingExit = null; } + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + + 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); @@ -227,7 +359,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(); } @@ -263,7 +405,56 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + // When compression changed the bytes, the original is persisted (into the + // session's media-originals dir when known, else the temp-dir fallback) + // and recorded on the attachment, so submit-time expansion can announce + // the compression and point the model at the full-fidelity copy. + // The edge cap comes from the host harness's [image] config (resolved per + // paste so a config reload applies immediately); hosts without a harness + // use the env/built-in default. + const compressed = await compressImageForModel(media.bytes, meta.mime, { + maxEdge: this.host.harness?.imageLimits?.maxEdgePx(), + telemetry: { + client: { + track: (event, properties) => + this.host.track(event, properties === undefined ? undefined : { ...properties }), + }, + source: 'tui_paste', + }, + }); + const sessionDir = this.host.session?.summary?.sessionDir; + // Dimensions come from the compression result, not parseImageMeta: the + // compressor reports display space (EXIF orientation applied) — the space + // the sent image, the caption, and ReadMediaFile region readback share — + // while parseImageMeta reads the raw pre-rotation header. + const attachment = compressed.changed + ? this.imageStore.addImage( + compressed.data, + compressed.mimeType, + compressed.width, + compressed.height, + { + path: await persistOriginalImage( + media.bytes, + meta.mime, + sessionDir === undefined ? {} : { dir: sessionMediaOriginalsDir(sessionDir) }, + ), + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: media.bytes.length, + mime: meta.mime, + }, + ) + : this.imageStore.addImage( + media.bytes, + meta.mime, + compressed.width || meta.width, + compressed.height || meta.height, + ); this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); this.host.state.ui.requestRender(); this.host.track('shortcut_paste', { kind: 'image' }); diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index a6d5ed013..2052c38c0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,5 +1,4 @@ -import chalk from 'chalk'; -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, @@ -18,6 +17,7 @@ import type { Session, SessionMetaUpdatedEvent, SkillActivatedEvent, + PluginCommandActivatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -67,6 +67,8 @@ import { selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; @@ -97,7 +99,7 @@ export interface SessionEventHost { patchLivePane(patch: Partial<LivePaneState>): void; resetLivePane(): void; showError(msg: string): void; - showStatus(msg: string, color?: string): void; + showStatus(msg: string, color?: ColorToken): void; showNotice(title: string, detail?: string): void; updateActivityPane(): void; track(event: string, props?: Record<string, unknown>): void; @@ -105,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; @@ -131,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(); @@ -147,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; @@ -241,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; @@ -251,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; @@ -324,6 +333,9 @@ export class SessionEventHandler { if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); } + if (event.reason === 'filtered') { + this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); + } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { this.host.streamingUI.setTodoList([]); @@ -355,6 +367,15 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); + + if (event.providerFinishReason === 'filtered') { + this.host.showNotice( + 'Provider safety policy blocked the response.', + `The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`, + ); + return; + } + if (event.finishReason !== 'max_tokens') return; const truncatedCount = this.host.streamingUI.markStepTruncated( @@ -375,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 { @@ -384,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 { @@ -397,7 +426,7 @@ export class SessionEventHandler { if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { this.markActiveAgentSwarmsCancelled(); - this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + this.host.showStatus('Interrupted by user', 'error'); return; } this.host.showError( @@ -409,6 +438,14 @@ export class SessionEventHandler { private handleThinkingDelta(event: ThinkingDeltaEvent): void { const { state, streamingUI } = this.host; + // Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible + // protocol) streams thinking deltas whose visible text is empty — only an + // opaque signature rides along. Such deltas carry nothing to render, so + // switching into the `thinking` pane mode here would stop the "waiting" + // moon spinner while no ThinkingComponent is ever created (it needs visible + // text), leaving a blank, spinner-less gap until the first real text/tool + // token arrives. Keep the moon up until actual thinking text shows up. + if (event.delta.length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { @@ -514,12 +551,17 @@ export class SessionEventHandler { } private handleToolProgress(event: ToolProgressEvent): void { - if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined) return; - tc.appendProgress(text); + if (event.update.kind === 'status') { + tc.appendProgress(text); + return; + } + if (event.update.kind === 'stdout' || event.update.kind === 'stderr') { + tc.appendLiveOutput(text); + } } private handleToolResult(event: ToolResultEvent): void { @@ -577,7 +619,7 @@ export class SessionEventHandler { private renderSwarmModeMarker(state: SwarmModeMarkerState): void { this.host.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(state, this.host.state.theme.colors), + new SwarmModeMarkerComponent(state), ); this.host.state.ui.requestRender(); } @@ -628,12 +670,7 @@ export class SessionEventHandler { } else if (change.kind === 'lifecycle') { this.pendingModelBlockedFallback = undefined; } - const marker = buildGoalMarker( - change, - state.theme.colors, - state.toolOutputExpanded, - change.actor, - ); + const marker = buildGoalMarker(change, state.toolOutputExpanded, change.actor); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); @@ -645,12 +682,7 @@ export class SessionEventHandler { if (change === undefined) return; this.pendingModelBlockedFallback = undefined; const { state } = this.host; - const marker = buildGoalMarker( - change, - state.theme.colors, - state.toolOutputExpanded, - 'model', - ); + const marker = buildGoalMarker(change, state.toolOutputExpanded, 'model'); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); @@ -706,7 +738,8 @@ export class SessionEventHandler { (session === undefined || this.host.session === session) && !this.host.aborted && this.host.state.appState.streamingPhase === 'idle' && - this.host.state.queuedMessages.length === 0 + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending ); } @@ -826,11 +859,10 @@ export class SessionEventHandler { } private handleSessionWarning(event: WarningEvent): void { - this.host.showStatus(`Warning: ${event.message}`, this.host.state.theme.colors.warning); + this.host.showStatus(`Warning: ${event.message}`, 'warning'); } private renderMcpServerStatus(server: McpServerStatusSnapshot): void { - const { state } = this.host; const key = mcpServerStatusKey(server); if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; this.renderedMcpServerStatusKeys.set(server.name, key); @@ -838,29 +870,28 @@ export class SessionEventHandler { const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); this.host.setAppState({ mcpServersSummary: summary || null }); - const colors = state.theme.colors; switch (server.status) { case 'connected': { const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, colors.success); + this.finalizeMcpServerStatusRow(server.name, message, 'success'); return; } case 'failed': { const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.error); + this.finalizeMcpServerStatusRow(server.name, message, 'error'); return; } case 'needs-auth': { const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); return; } case 'disabled': this.finalizeMcpServerStatusRow( server.name, `MCP server "${server.name}" disabled`, - colors.textMuted, + 'textMuted', ); return; case 'pending': @@ -877,14 +908,14 @@ export class SessionEventHandler { existing.setLabel(label); return; } - const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); + const tint = (s: string): string => currentTheme.fg('textMuted', s); const spinner = new MoonLoader(state.ui, 'braille', tint, label); state.transcriptContainer.addChild(spinner); this.mcpServerStatusSpinners.set(name, spinner); state.ui.requestRender(); } - private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { const { state } = this.host; const spinner = this.mcpServerStatusSpinners.get(name); if (spinner === undefined) { @@ -892,7 +923,7 @@ export class SessionEventHandler { return; } spinner.stop(); - const status = new StatusMessageComponent(message, state.theme.colors, color); + const status = new StatusMessageComponent(message, color); const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { @@ -921,6 +952,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({ @@ -935,7 +985,11 @@ export class SessionEventHandler { event: CompactionCompletedEvent, sendQueued: (item: QueuedMessage) => void, ): void { - this.host.streamingUI.endCompaction(event.result.tokensBefore, event.result.tokensAfter); + this.host.streamingUI.endCompaction( + event.result.tokensBefore, + event.result.tokensAfter, + event.result.summary, + ); this.finishCompaction(sendQueued); } @@ -950,14 +1004,18 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + this.host.state.queuedMessageDispatchPending = true; + } this.host.setAppState({ isCompacting: false, streamingPhase: 'idle', }); this.host.resetLivePane(); - const next = this.host.shiftQueuedMessage(); if (next !== undefined) { setTimeout(() => { + this.host.state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); } @@ -992,6 +1050,9 @@ export class SessionEventHandler { if (event.type === 'background.task.started') { if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index b7d761346..00ee5a64b 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -21,6 +22,7 @@ import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; import { appStateFromResumeAgent, backgroundOrigin, @@ -35,16 +37,19 @@ 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'; import type { TUIState } from '../tui-state'; type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; +type CompactionReplayRecord = Extract<AgentReplayRecord, { type: 'compaction' }>; type GoalReplayLifecycleChange = GoalChange & { readonly kind: 'lifecycle' }; export interface SessionReplayHost { @@ -54,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 { @@ -71,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); @@ -178,6 +201,9 @@ export class SessionReplayRenderer { case 'message': this.renderMessage(context, record.message); return; + case 'compaction': + this.renderCompaction(context, record); + return; case 'goal_updated': this.renderGoalReplayRecord(context, record); return; @@ -245,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; @@ -276,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( @@ -373,6 +429,57 @@ 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; + if (record.result === 'cancelled') { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction cancelled', 'plain'), + compactionData: { + result: 'cancelled', + instruction: record.instruction, + }, + }); + return; + } + + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction complete', 'plain'), + compactionData: { + summary: record.result.summary, + tokensBefore: record.result.tokensBefore, + tokensAfter: record.result.tokensAfter, + instruction: record.instruction, + }, + }); + } + private renderGoalReplayRecord(context: ReplayRenderContext, record: GoalReplayRecord): void { this.flushAssistant(context); const { change } = record; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 881489ea7..ae5eac768 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -2,6 +2,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; @@ -34,6 +35,7 @@ export interface StreamingUIHost { deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; } export class StreamingUIController { @@ -265,6 +267,41 @@ export class StreamingUIController { return true; } + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + /** Registers a tool call that arrived via tool.call.started. * Clears any pending streaming state for this id, updates or creates the * component, and returns whether the call was new (no previous entry). */ @@ -499,6 +536,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { @@ -522,9 +560,15 @@ export class StreamingUIController { const next = this.host.shiftQueuedMessage(); if (next !== undefined) { + // The message is out of the queue but not yet sent. Mark the dispatch + // pending *before* setAppState — that call synchronously retries + // queued-goal promotion, which would otherwise see an empty queue and an + // idle phase and start a goal ahead of this message. + state.queuedMessageDispatchPending = true; this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); setTimeout(() => { + state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); return; @@ -553,10 +597,7 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', }; - const component = new AssistantMessageComponent( - state.theme.markdownTheme, - state.theme.colors, - ); + const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); @@ -567,12 +608,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; } @@ -584,7 +629,6 @@ export class StreamingUIController { this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( fullText, - state.theme.colors, true, 'live', state.ui, @@ -602,6 +646,7 @@ export class StreamingUIController { this._activeThinkingComponent.finalize(); this._activeThinkingComponent = undefined; this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -611,9 +656,7 @@ export class StreamingUIController { const tc = new ToolCallComponent( toolCall, undefined, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) tc.setExpanded(true); @@ -650,6 +693,7 @@ export class StreamingUIController { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); return; } @@ -657,15 +701,14 @@ export class StreamingUIController { const completed = new ToolCallComponent( matchedCall, result, - state.theme.colors, state.ui, - state.theme.markdownTheme, state.appState.workDir, ); if (state.toolOutputExpanded) completed.setExpanded(true); state.transcriptContainer.addChild(completed); state.ui.requestRender(); } + this.host.mergeCurrentTurnSteps(); } setTodoList(todos: readonly TodoItem[]): void { @@ -684,16 +727,19 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text); this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); + if (state.toolOutputExpanded) { + block.setExpanded(true); + } state.ui.requestRender(); } - endCompaction(tokensBefore?: number, tokensAfter?: number): void { + endCompaction(tokensBefore?: number, tokensAfter?: number, summary?: string): void { const block = this._activeCompactionBlock; if (block === undefined) return; - block.markDone(tokensBefore, tokensAfter); + block.markDone(tokensBefore, tokensAfter, summary); this._activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } @@ -780,7 +826,7 @@ export class StreamingUIController { private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { const { state } = this.host; - const group = new AgentGroupComponent(state.theme.colors, state.ui); + const group = new AgentGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { @@ -837,7 +883,7 @@ export class StreamingUIController { private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { const { state } = this.host; - const group = new ReadGroupComponent(state.theme.colors, state.ui); + const group = new ReadGroupComponent(state.ui); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 5f774991d..deb407bfc 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -2,7 +2,7 @@ import type { BackgroundTaskInfo, Event, } from '@moonshot-ai/kimi-code-sdk'; -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { AgentSwarmProgressComponent, @@ -107,6 +107,12 @@ export class SubAgentEventHandler { name: event.name, argumentsPart: event.argumentsPart ?? null, }); + } else if ( + event.type === 'tool.progress' && + (event.update.kind === 'stdout' || event.update.kind === 'stderr') && + event.update.text !== undefined + ) { + toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text); } else if (event.type === 'tool.result') { toolCall.finishSubToolCall({ tool_call_id: `${childAgentId}:${event.toolCallId}`, @@ -523,7 +529,6 @@ export class SubAgentEventHandler { const progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), - colors: this.host.state.theme.colors, availableGridHeight: () => this.agentSwarmGridHeight(), requestRender: () => { this.requestRender(); diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index a7a0c2053..6994b13b8 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,15 +1,15 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; +import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; -import type { ColorPalette } from '../theme'; +import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { readonly tasksBrowser: TasksBrowserState | undefined; - readonly theme: { readonly colors: ColorPalette }; + readonly theme: Theme; readonly terminal: ProcessTerminal; readonly ui: TUI; readonly editor: CustomEditor; @@ -77,7 +77,6 @@ export class TasksBrowserController { tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: state.theme.colors, ...this.buildCallbacks(), }, state.terminal, @@ -167,7 +166,6 @@ export class TasksBrowserController { taskId: viewer.taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, @@ -229,7 +227,6 @@ export class TasksBrowserController { tailOutput: browser.tailOutput, tailLoading: browser.tailLoading, flashMessage: browser.flashMessage, - colors: this.host.state.theme.colors, ...this.buildCallbacks(), }); this.host.state.ui.requestRender(); @@ -343,7 +340,6 @@ export class TasksBrowserController { taskId, info, output, - colors: state.theme.colors, onClose: () => { this.closeOutputViewer(); }, diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index fe08d17dc..15e3608f8 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -10,11 +10,11 @@ */ import chalk from 'chalk'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; -import type { ColorPalette } from '../theme/colors'; +import { currentTheme } from '../theme'; /** Frame interval for the rainbow flow animation. */ export const DANCE_FRAME_MS = 110; @@ -44,8 +44,8 @@ const LIGHT_RAINBOW = [ '#354CB5', ] as const; -function getDanceRainbowPalette(colors: ColorPalette): readonly [string, ...string[]] { - return colors.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; } /** Paint a string character-by-character through a palette, skipping spaces. */ @@ -110,13 +110,12 @@ export function isRainbowDancing(): boolean { } export function renderDanceWelcomeHeader( - colors: ColorPalette, logo: readonly [string, string], textWidth: number, rightRow1: string, ): string[] { const phase = currentDanceView?.phase ?? 0; - const palette = getDanceRainbowPalette(colors); + const palette = getDanceRainbowPalette(); const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const rightRow0 = truncateToWidth( @@ -131,8 +130,8 @@ export function renderDanceWelcomeHeader( ]; } -export function renderDanceFooterModel(modelLabel: string, colors: ColorPalette): string { - return rainbowText(modelLabel, getDanceRainbowPalette(colors), currentDanceView?.phase ?? 0); +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); } /** @@ -233,7 +232,7 @@ export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlas // The status line dims the whole message, which buried the command in the // hint. Paint just the command in the brand color (bold) so it reads as a // command; chalk nesting resumes the dim run right after it. - const cmd = (text: string): string => chalk.hex(host.state.theme.colors.primary).bold(text); + const cmd = (text: string): string => currentTheme.boldFg('primary', text); const sub = parsed.args.trim().toLowerCase(); if (sub === 'off') { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 10084ea9e..62d1b2370 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,15 +1,6 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - deleteAllKittyImages, - type Component, - type Focusable, - getCapabilities, - type SlashCommand, - Spacer, -} from '@earendil-works/pi-tui'; -import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -21,17 +12,31 @@ import type { PromptPart, Session, } from '@moonshot-ai/kimi-code-sdk'; -import chalk from 'chalk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + Spacer, +} from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; +import { openUrl } from '#/utils/open-url'; import { getInputHistoryFile } from '#/utils/paths'; import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; +import { quoteShellArg } from '#/utils/shell-quote'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; +import { BannerProvider } from './banner/banner-provider'; +import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, buildSkillSlashCommands, isExperimentalFlagEnabled, setExperimentalFeatures, @@ -39,11 +44,13 @@ import { type KimiSlashCommand, type SkillListSession, } from './commands'; +import * as slashCommands from './commands/dispatch'; +import { BannerComponent } from './components/chrome/banner'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; -import { CHROME_GUTTER } from './constant/rendering'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; import { WelcomeComponent } from './components/chrome/welcome'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -55,17 +62,11 @@ import { import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; -import { SessionPickerComponent } from './components/dialogs/session-picker'; -import { AuthFlowController } from './controllers/auth-flow'; -import { BtwPanelController } from './controllers/btw-panel'; -import { EditorKeyboardController } from './controllers/editor-keyboard'; -import { SessionEventHandler } from './controllers/session-event-handler'; -import * as slashCommands from './commands/dispatch'; -import { SessionReplayRenderer } from './controllers/session-replay'; -import { StreamingUIController } from './controllers/streaming-ui'; -import { TasksBrowserController } from './controllers/tasks-browser'; -import { installRainbowDance } from './easter-eggs/dance'; -import { FileMentionProvider } from './components/editor/file-mention-provider'; +import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { + FileMentionProvider, + type SlashAutocompleteCommand, +} from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; @@ -74,11 +75,14 @@ import { GoalCompletionMessageComponent, GoalSetMessageComponent, } from './components/messages/goal-panel'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -91,8 +95,17 @@ import { NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, } from './constant/kimi-tui'; +import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; -import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; +import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; +import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; +import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { SessionEventHandler } from './controllers/session-event-handler'; +import { SessionReplayRenderer } from './controllers/session-replay'; +import { StreamingUIController } from './controllers/streaming-ui'; +import { TasksBrowserController } from './controllers/tasks-browser'; +import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -100,9 +113,9 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import { createKimiTUIThemeBundle } from './theme/bundle'; -import type { ResolvedTheme } from './theme/colors'; -import type { Theme } from './theme/index'; +import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; +import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, type AppState, @@ -114,21 +127,34 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { createTUIState, type TUIState } from './tui-state'; -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'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; -import { openUrl } from '#/utils/open-url'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatBashOutputForDisplay } from './utils/shell-output'; +import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; 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 { nextTranscriptId } from './utils/transcript-id'; +import { + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -141,17 +167,32 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; readonly workDir: string; readonly startupNotice?: string; - readonly resolvedTheme?: ResolvedTheme; readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +type LoadingTipKind = 'moon' | 'composing'; + +function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { + if (mode === 'waiting' || mode === 'tool') return 'moon'; + if (mode === 'composing') return 'composing'; + return undefined; +} + +function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto @@ -162,11 +203,13 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { return { model: '', workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -177,6 +220,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, + disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, availableModels: {}, @@ -184,6 +228,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionTitle: null, goal: null, mcpServersSummary: null, + banner: undefined, }; } @@ -193,6 +238,9 @@ interface SendMessageOptions { readonly hasMedia?: boolean; } +/** How long the one-shot "moved to background" footer hint stays visible. */ +const DETACH_HINT_DISPLAY_MS = 4_000; + export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -203,6 +251,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; @@ -212,6 +262,7 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; @@ -219,7 +270,17 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; + 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; @@ -228,6 +289,9 @@ export class KimiTUI { readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined; + // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. @@ -244,10 +308,10 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise<void>; - track( - event: string, - properties?: Parameters<KimiHarness['track']>[1], - ): void { + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ + public exitOpenUrl: string | undefined; + + track(event: string, properties?: Parameters<KimiHarness['track']>[1]): void { this.harness.track(event, properties); } @@ -264,7 +328,6 @@ export class KimiTUI { model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, - resolvedTheme: startupInput.resolvedTheme, }; this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; @@ -310,14 +373,15 @@ 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 { - const slashCommands: SlashCommand[] = this.getSlashCommands().map((cmd) => { + const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { const completer = cmd.completeArgs; return { name: cmd.name, + aliases: cmd.aliases, description: cmd.description, ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), ...(completer !== undefined @@ -329,8 +393,20 @@ export class KimiTUI { slashCommands, this.state.appState.workDir, this.fdPath, + this.state.appState.additionalDirs, + () => this.state.appState.inputMode, ); this.state.editor.setAutocompleteProvider(provider); + + const argumentHints = new Map<string, string>(); + for (const cmd of slashCommands) { + if (cmd.argumentHint === undefined) continue; + argumentHints.set(cmd.name, cmd.argumentHint); + for (const alias of cmd.aliases ?? []) { + argumentHints.set(alias, cmd.argumentHint); + } + } + this.state.editor.setArgumentHints(argumentHints); } refreshSlashCommandAutocomplete(): void { @@ -360,6 +436,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 // ========================================================================= @@ -375,8 +474,7 @@ export class KimiTUI { try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { - const failed = - migrationResult.decision === 'now' && migrationResult.migrated === false; + const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; this.disposeTerminalTracking(); this.state.ui.stop(); await this.onExit?.(failed ? 1 : 0); @@ -409,12 +507,60 @@ export class KimiTUI { } } + private async loadBanner(): Promise<void> { + const provider = new BannerProvider(this.state.appState.version); + const displayState = await readBannerDisplayState(); + const now = new Date(); + const banner = await provider.load(fetch, { + state: displayState, + now, + }); + this.state.appState.banner = banner; + if (banner === null) return; + + this.renderBanner(); + this.state.ui.requestRender(); + + if (banner.display === 'always') return; + try { + await writeBannerDisplayState({ + version: 1, + shown: { + ...displayState.shown, + [banner.key]: { lastShownAt: now.toISOString() }, + }, + }); + } catch { + // Best-effort: banner display state should never block startup. + } + } + + private renderBanner(): void { + if (this.state.appState.banner === null || this.state.appState.banner === undefined) { + return; + } + if (this.state.transcriptContainer.children.some((child) => child instanceof BannerComponent)) { + return; + } + const welcomeIndex = this.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const banner = new BannerComponent(this.state.appState.banner); + if (welcomeIndex >= 0) { + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); + } else { + this.state.transcriptContainer.children.unshift(banner); + } + this.state.transcriptContainer.invalidate(); + } + private async initMainTui(): Promise<boolean> { const shouldReplayHistory = await this.init(); // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); + void this.loadBanner(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); @@ -424,11 +570,27 @@ export class KimiTUI { } private startEventLoop(): void { + // Dispose any previous focus/clipboard/theme tracking so re-entering the + // event loop (e.g. a future TUI reconnect) can't stack duplicate listeners. + this.disposeTerminalTracking(); this.state.ui.start(); + this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); } + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { + this.state.ui.requestRender(); + }, + }); + this.clipboardImageHintController.start(); + } + private startBackgroundFdAutocomplete(): void { if (this.fdPath !== null || this.fdDownloadStarted) return; this.fdDownloadStarted = true; @@ -448,16 +610,11 @@ export class KimiTUI { try { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { - const parts: string[] = [c.providerName]; - if (c.added > 0) parts.push(`+${String(c.added)} model${c.added > 1 ? 's' : ''}`); - if (c.removed > 0) parts.push(`-${String(c.removed)} model${c.removed > 1 ? 's' : ''}`); - this.showStatus(parts.join(' · ') + '.'); + if (c.added <= 0) continue; + this.showStatus(`${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`); } for (const f of result.failed) { - this.showStatus( - `Skipped refreshing ${f.provider}: ${f.reason}`, - this.state.theme.colors.warning, - ); + this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); } } catch { // Best-effort: startup must not crash on background refresh failures. @@ -476,25 +633,41 @@ export class KimiTUI { } if (shouldReplayHistory) { await this.sessionReplay.hydrateFromReplay(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(this.session); } void this.fetchSessions(); if (this.session !== undefined) { this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); + } + + private async showSessionWarnings(session: Session): Promise<void> { + try { + const warnings = await session.getSessionWarnings(); + if (this.session !== session) return; + for (const warning of warnings) { + const severity = warning.severity === 'error' ? 'error' : 'warning'; + this.showStatus(`Warning: ${warning.message}`, severity); + } + } catch { + // Best-effort: startup must not block on warning retrieval. + } } private async showTmuxKeyboardWarningIfNeeded(): Promise<void> { const warning = await detectTmuxKeyboardWarning(); if (warning === undefined || this.aborted) return; - this.showStatus(warning, this.state.theme.colors.warning); + this.showStatus(warning, 'warning'); } private async init(): Promise<boolean> { @@ -507,12 +680,15 @@ export class KimiTUI { let session: Session | undefined; let shouldReplayHistory = false; const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; - const createSessionOptions: CreateSessionOptions = { + const createSessionOptions: MutableCreateSessionOptions = { workDir, model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, }; + if (this.state.appState.additionalDirs.length > 0) { + createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; + } try { if (isResumeStartup) { @@ -533,7 +709,8 @@ export class KimiTUI { if (resolve(target.workDir) !== resolve(workDir)) { this.state.ui.stop(); process.stderr.write( - `${chalk.hex(this.state.theme.colors.warning)( + `${currentTheme.fg( + 'warning', `Session "${startup.sessionFlag}" was created under a different directory.\n` + ` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`, )}\n\n`, @@ -542,13 +719,19 @@ export class KimiTUI { `Session "${startup.sessionFlag}" was created under a different directory.`, ); } - session = await this.harness.resumeSession({ id: startup.sessionFlag }); + session = await this.harness.resumeSession({ + id: startup.sessionFlag, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { const sessions = await this.harness.listSessions({ workDir }); const target = sessions[0]; if (target !== undefined) { - session = await this.harness.resumeSession({ id: target.id }); + session = await this.harness.resumeSession({ + id: target.id, + additionalDirs: createSessionOptions.additionalDirs, + }); shouldReplayHistory = true; } else { session = await this.harness.createSession(createSessionOptions); @@ -561,8 +744,11 @@ export class KimiTUI { } else { session = await this.harness.createSession(createSessionOptions); } - if (session !== undefined && startup.model !== undefined && isResumeStartup) { - await session.setModel(startup.model); + if (session !== undefined && shouldReplayHistory) { + await this.applyStartupModesToResumedSession(session); + if (startup.model !== undefined) { + await session.setModel(startup.model); + } } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; @@ -575,6 +761,7 @@ export class KimiTUI { } await this.setSession(session); await this.syncRuntimeState(session); + this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; } @@ -585,18 +772,42 @@ export class KimiTUI { this.unregisterSignalHandlers(); this.aborted = true; this.streamingUI.discardPending(); - this.editorKeyboard.clearPendingExit(); + // Stop background polling, streaming intervals, and per-component timers + // before tearing the UI down, so they can't keep firing requestRender after + // stop() returns (or leak when stop() runs without process.exit). + this.tasksBrowserController.close(); + this.btwPanelController.clear(); + this.stopActivitySpinner(); + this.streamingUI.disposeActiveCompactionBlock(); + this.streamingUI.resetToolUi(); + this.disposeTranscriptChildren(); + this.editorKeyboard.dispose(); + this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); } this.reverseRpcDisposers.length = 0; this.disposeTerminalTracking(); - await this.closeSession('shutting down'); - await this.harness.close(); - this.sessionEventHandler.stopAllMcpServerStatusSpinners(); - this.uninstallRainbowDance(); - await this.state.terminal.drainInput(); - this.state.ui.stop(); + // Restore the terminal even if closing the session / harness throws — a + // SIGTERM during a network or MCP shutdown must not leave the user stuck in + // raw mode with a hidden cursor. + try { + await this.closeSession('shutting down'); + await this.harness.close(); + } finally { + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.uninstallRainbowDance(); + try { + await this.state.terminal.drainInput(); + } catch { + // best effort — the terminal may already be dead (SIGHUP / EIO). + } + try { + this.state.ui.stop(); + } catch { + // best effort terminal restore. + } + } if (this.onExit) { await this.onExit(exitCode); } @@ -660,11 +871,17 @@ export class KimiTUI { private emergencyTerminalExit(exitCode = 129): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); + // Best-effort terminal restore: stop() may not have run (SIGHUP) or may + // have thrown (SIGTERM cleanup failure), so recover raw mode / cursor / + // bracketed paste before exiting instead of leaving the user's shell broken. + restoreTerminalModes(); process.exit(exitCode); } private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.clipboardImageHintController?.stop(); + this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } @@ -700,16 +917,158 @@ 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 stored with a leading `!` so ↑ recall can tell them + // apart from prompts and restore bash mode (see CustomEditor's mode-aware + // history navigation). The `!` is stripped again when the entry is recalled. + const historyText = wasBashMode ? `!${text}` : text; + void this.persistInputHistory(historyText); + 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(); + + this.track('shell_command'); + + 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) { @@ -791,18 +1150,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, @@ -811,6 +1174,7 @@ export class KimiTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + mode, }); this.track('input_queue'); } @@ -839,10 +1203,15 @@ export class KimiTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { - this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; - this.sendMessageInternal(session, item.text, { - parts: item.parts, - imageAttachmentIds: item.imageAttachmentIds, + 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, + imageAttachmentIds: item.imageAttachmentIds, + }); }); } @@ -881,6 +1250,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 || @@ -970,6 +1352,10 @@ export class KimiTUI { return this.state.transcriptEntries.length > 0; } + setExitOpenUrl(url: string): void { + this.exitOpenUrl = url; + } + async getStartupMcpMs(): Promise<number> { const session = this.session; if (session === undefined) return 0; @@ -983,6 +1369,9 @@ export class KimiTUI { setAppState(patch: Partial<AppState>): void { if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); @@ -992,6 +1381,7 @@ export class KimiTUI { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); } + if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); } @@ -1008,6 +1398,12 @@ export class KimiTUI { this.state.ui.requestRender(); } + private syncAdditionalDirs(session: Session): void { + const additionalDirs = session.summary?.additionalDirs ?? []; + if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; + this.setAppState({ additionalDirs: [...additionalDirs] }); + } + // ========================================================================= // Session Runtime // ========================================================================= @@ -1024,14 +1420,17 @@ export class KimiTUI { if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } - return this.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: - this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', + thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, - }); + }; + if (this.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...this.state.appState.additionalDirs]; + } + return this.harness.createSession(options); } async setSession(session: Session): Promise<void> { @@ -1040,17 +1439,15 @@ export class KimiTUI { this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); + this.syncAdditionalDirs(session); } async syncRuntimeState(session: Session = this.requireSession()): Promise<void> { - const [status, goalResult] = await Promise.all([ - session.getStatus(), - session.getGoal(), - ]); + const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); this.setAppState({ sessionId: session.id, model: status.model ?? '', - thinking: status.thinkingLevel !== 'off', + thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, swarmMode: status.swarmMode ?? false, @@ -1060,6 +1457,41 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.syncAdditionalDirs(session); + } + + // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed + // session may already be in plan mode from its persisted records, and + // re-entering plan mode throws, so only enable it when it is not active yet. + // setPermission is idempotent and needs no such guard. + private async applyStartupModesToResumedSession(session: Session): Promise<void> { + const { startup } = this.options; + if (startup.auto) { + await session.setPermission('auto'); + } else if (startup.yolo) { + await session.setPermission('yolo'); + } + if (startup.plan) { + const status = await session.getStatus(); + if (!status.planMode) { + await session.setPlanMode(true); + } + } + } + + // Re-apply startup flags that the user explicitly passed on the command line. + // syncRuntimeState and session-replay hydration can both read stale persisted + // values, so this guarantees the footer reflects the CLI intent. + private applyStartupPermissionAndPlanToAppState(): void { + const { startup } = this.options; + if (startup.auto) { + this.setAppState({ permissionMode: 'auto' }); + } else if (startup.yolo) { + this.setAppState({ permissionMode: 'yolo' }); + } + if (startup.plan) { + this.setAppState({ planMode: true }); + } } // Plan mode is set by createSession — do not re-enter it here. @@ -1094,6 +1526,7 @@ export class KimiTUI { for (const dispose of this.reverseRpcDisposers) { dispose(); } + this.reverseRpcDisposers.length = 0; } private registerSessionHandlers(session: Session): void { @@ -1105,10 +1538,14 @@ export class KimiTUI { session.setQuestionHandler(createQuestionAskHandler(this.questionController)); } - async fetchSessions(): Promise<void> { + async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise<void> { this.state.loadingSessions = true; + this.state.sessionsScope = scope; try { - const sessions = await this.harness.listSessions({ workDir: this.state.appState.workDir }); + const sessions = + scope === 'all' + ? await this.harness.listSessions({}) + : await this.harness.listSessions({ workDir: this.state.appState.workDir }); this.state.sessions = sessionRowsForPicker( sessions, this.state.appState.sessionId, @@ -1132,7 +1569,6 @@ export class KimiTUI { this.streamingUI.discardPending(); this.state.queuedMessages = []; this.state.swarmModeEntry = undefined; - this.harness.interactiveAgentId = MAIN_AGENT_ID; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); @@ -1147,6 +1583,18 @@ export class KimiTUI { this.updateQueueDisplay(); } + private async showResumeOtherWorkDirHint(session: SessionRow): Promise<void> { + this.hideSessionPicker(); + const command = `cd ${quoteShellArg(session.work_dir)} && kimi --resume ${quoteShellArg(session.id)}`; + const message = `Current session is in a different working directory.\n To resume, run: ${command}`; + try { + await copyTextToClipboard(command); + this.showStatus(`${message}\n Command copied to clipboard`, 'warning'); + } catch { + this.showStatus(`${message}\n Failed to copy command to clipboard`, 'warning'); + } + } + private async resumeSession(targetSessionId: string): Promise<boolean> { if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); @@ -1181,6 +1629,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 */ } @@ -1195,9 +1644,10 @@ export class KimiTUI { } const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> { @@ -1217,15 +1667,17 @@ 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 */ } this.sessionEventHandler.startSubscription(); const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { - this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); } this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async createNewSession(): Promise<void> { @@ -1257,12 +1709,27 @@ 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 */ } this.sessionEventHandler.startSubscription(); this.clearTranscriptAndRedraw(); this.showStatus(`Started a new session (${session.id}).`); + void this.showSessionWarnings(session); + void this.showConfigWarningsIfAny(); + } + + /** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */ + private async showConfigWarningsIfAny(): Promise<void> { + try { + const { warnings } = await this.harness.getConfigDiagnostics(); + for (const warning of warnings) { + this.showStatus(warning, 'warning'); + } + } catch { + /* diagnostics are best-effort */ + } } // ========================================================================= @@ -1272,12 +1739,15 @@ export class KimiTUI { private createTranscriptComponent(entry: TranscriptEntry): Component | null { if (entry.compactionData !== undefined) { const data = entry.compactionData; - const block = new CompactionComponent( - this.state.theme.colors, - this.state.ui, - data.instruction, - ); - block.markDone(data.tokensBefore, data.tokensAfter); + const block = new CompactionComponent(this.state.ui, data.instruction); + if (data.result === 'cancelled') { + block.markCanceled(); + } else { + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } + } return block; } @@ -1286,46 +1756,39 @@ export class KimiTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, this.state.theme.colors, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( entry.skillName ?? entry.content, entry.skillArgs, - this.state.theme.colors, 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 ?? {}, - this.state.theme.colors, - ); + return new CronMessageComponent(entry.content, entry.cronData ?? {}); case 'goal': if (entry.goalData?.kind === 'created') { - return new GoalSetMessageComponent(this.state.theme.colors); + return new GoalSetMessageComponent(); } if (entry.goalData?.kind === 'lifecycle') { - return buildGoalMarker( - entry.goalData.change, - this.state.theme.colors, - this.state.toolOutputExpanded, - ); + return buildGoalMarker(entry.goalData.change, this.state.toolOutputExpanded); } return null; case 'assistant': { if (entry.content.trimStart().startsWith('✓ Goal complete')) { - return new GoalCompletionMessageComponent(entry.content, this.state.theme.colors); + return new GoalCompletionMessageComponent(entry.content); } - const component = new AssistantMessageComponent( - this.state.theme.markdownTheme, - this.state.theme.colors, - ); + const component = new AssistantMessageComponent(); component.updateContent(entry.content); return component; } case 'thinking': { - const thinking = new ThinkingComponent(entry.content, this.state.theme.colors, true); + const thinking = new ThinkingComponent(entry.content, true); if (this.state.toolOutputExpanded) thinking.setExpanded(true); return thinking; } @@ -1334,33 +1797,25 @@ export class KimiTUI { const tc = new ToolCallComponent( entry.toolCallData, entry.toolCallData.result, - this.state.theme.colors, this.state.ui, - this.state.theme.markdownTheme, this.state.appState.workDir, ); if (this.state.toolOutputExpanded) tc.setExpanded(true); return tc; } if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'status': if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent( - entry.backgroundAgentStatus, - this.state.theme.colors, - ); + return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' - ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) - : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + ? new NoticeMessageComponent(entry.content, entry.detail) + : new StatusMessageComponent(entry.content, entry.color); case 'welcome': return null; default: @@ -1374,12 +1829,24 @@ 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(); } } - private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { - if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; + private appendApprovalTranscriptEntry( + request: ApprovalRequest, + response: ApprovalResponse, + ): void { + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; const parts: string[] = []; switch (response.decision) { case 'approved': @@ -1407,13 +1874,11 @@ export class KimiTUI { private renderWelcome(): void { if ( - this.state.transcriptContainer.children.some( - (child) => child instanceof WelcomeComponent, - ) + this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent) ) { return; } - const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); + const welcome = new WelcomeComponent(this.state.appState); this.state.transcriptContainer.addChild(welcome); } @@ -1422,6 +1887,16 @@ export class KimiTUI { this.state.terminal.write(deleteAllKittyImages()); } + private disposeTranscriptChildren(): void { + // Dispose disposable children (e.g. ShellRunComponent's 1s timer, + // ThinkingComponent's spinner) before dropping them, so a /clear, session + // switch, or shutdown 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(); + } + } + private clearTranscriptAndRedraw(): void { this.streamingUI.discardPending(); this.state.transcriptEntries = []; @@ -1429,6 +1904,7 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.disposeTranscriptChildren(); this.state.transcriptContainer.clear(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); @@ -1436,24 +1912,251 @@ export class KimiTUI { this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); + // Session resets (/new, /clear, session switch) want a pristine screen. + // Force a destructive full render: the renderer's collapse repaint + // intentionally preserves scrollback, which would leave the previous + // session's text above the welcome banner. + this.state.ui.requestRender(true); } - showStatus(message: string, color?: string): void { - this.state.transcriptContainer.addChild( - new StatusMessageComponent(message, this.state.theme.colors, color), - ); + 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; + + // Reclaim image bytes referenced by trimmed user messages. The transcript + // renders historical thumbnails via imageStore.get(id), so an attachment can + // only be dropped once its owning user message leaves the transcript. + for (const entry of toRemove) { + if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) { + this.imageStore.removeMany(entry.imageAttachmentIds); + } + } + + 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(); } showNotice(title: string, detail?: string): void { - this.state.transcriptContainer.addChild( - new NoticeMessageComponent(title, detail, this.state.theme.colors), - ); + this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail)); this.state.ui.requestRender(); } showError(message: string): void { - this.showStatus(`Error: ${message}`, this.state.theme.colors.error); + this.showStatus(`Error: ${message}`, 'error'); } showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { @@ -1461,7 +2164,7 @@ export class KimiTUI { } showProgressSpinner(label: string): LoginProgressSpinnerHandle { - const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); + const tint = (s: string): string => currentTheme.fg('primary', s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); this.state.transcriptContainer.addChild(spinner); @@ -1469,11 +2172,14 @@ export class KimiTUI { return { stop: ({ ok, label: finalLabel }) => { spinner.stop(); - const tone = ok ? this.state.theme.colors.success : this.state.theme.colors.error; + const tone = ok ? 'success' : 'error'; const symbol = ok ? '✓' : '✗'; - spinner.setText(chalk.hex(tone)(`${symbol} ${finalLabel}`)); + spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } @@ -1485,7 +2191,6 @@ export class KimiTUI { url: auth.verificationUriComplete, code: auth.userCode, hint: 'Press Ctrl-C to cancel', - colors: this.state.theme.colors, }), ); this.state.ui.requestRender(); @@ -1498,6 +2203,23 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; + } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; @@ -1529,6 +2251,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1540,13 +2263,14 @@ export class KimiTUI { } case 'composing': { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => - chalk.hex(this.state.theme.colors.primary)(s), + currentTheme.fg('primary', s), ); this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1559,6 +2283,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1567,6 +2292,10 @@ export class KimiTUI { case 'session': { this.stopActivitySpinner(); this.syncAgentSwarmActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); break; } } @@ -1580,6 +2309,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; @@ -1597,7 +2331,6 @@ export class KimiTUI { this.state.queueContainer.addChild( new QueuePaneComponent({ messages: queued, - colors: this.state.theme.colors, isCompacting: this.state.appState.isCompacting, isStreaming: this.state.appState.streamingPhase !== 'idle', canSteerImmediately: !this.deferUserMessages, @@ -1607,40 +2340,177 @@ 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); + } + // Expanding/collapsing shifts content above the viewport; the clamped + // differential render would paint a second copy below the stale one in + // scrollback. This is a deliberate user action (like /clear), so do a + // destructive full render: scrollback holds exactly one copy and the + // expanded output can be read by scrolling up. + this.state.ui.requestRender(true); + } + + toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); + 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); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); } } + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); this.state.ui.requestRender(); } updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); - const colorToken = highlighted ? this.state.theme.colors.primary : this.state.theme.colors.border; + 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) => chalk.hex(colorToken)(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(); } - applyTheme(theme: Theme, resolved?: ResolvedTheme): void { - const nextTheme = createKimiTUIThemeBundle(theme, resolved); - Object.assign(this.state.theme.colors, nextTheme.colors); - this.state.theme.resolvedTheme = nextTheme.resolvedTheme; - this.state.theme.styles = nextTheme.styles; - this.state.theme.markdownTheme = nextTheme.markdownTheme; - this.setAppState({ theme }); + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise<void> { + const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName); + currentTheme.setPalette(palette); + this.setAppState({ theme: themeName }); this.updateEditorBorderHighlight(); + // Force every historical message to re-render so Markdown/Text caches + // (which hold old ANSI colour codes) are cleared. + this.state.transcriptContainer.invalidate(); this.state.ui.requestRender(true); } refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); - if (this.state.appState.theme !== 'auto') return; + if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { - this.applyResolvedAutoTheme(resolved); + void this.applyResolvedAutoTheme(resolved); }); } @@ -1649,10 +2519,16 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } - private applyResolvedAutoTheme(resolved: ResolvedTheme): void { + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise<void> { if (this.state.appState.theme !== 'auto') return; - if (this.state.theme.resolvedTheme === resolved) return; - this.applyTheme('auto', resolved); + const palette = getBuiltInPalette(resolved); + if (currentTheme.palette === palette) return; + currentTheme.setPalette(palette); + this.updateEditorBorderHighlight(); + // Repaint already-rendered transcript entries (status/markdown caches hold + // old ANSI codes), matching applyTheme()'s behaviour. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); } private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { @@ -1665,7 +2541,9 @@ export class KimiTUI { ); } - private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { + private shouldPlaceActivitySpinnerInAgentSwarm( + effectiveMode: EffectiveActivityPaneMode, + ): boolean { return ( this.sessionEventHandler.hasActiveAgentSwarmToolCall() && (effectiveMode === 'waiting' || effectiveMode === 'tool') @@ -1677,6 +2555,7 @@ export class KimiTUI { } private syncTerminalProgress(active: boolean): void { + if (!this.state.terminalState.supportsProgress) return; if (this.state.terminalState.progressActive === active) return; this.state.terminal.setProgress(active); this.state.terminalState.progressActive = active; @@ -1726,7 +2605,18 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); + // Measure overflow against the restored tree (editor mounted), not the tall + // panel just removed — otherwise a short session with a tall panel looks like + // it overflows and we take a full clear/home that yanks the editor to the top. + // Treat an exact one-screen fill as overflowing too: a full redraw is safe + // there (no blank tail) and clears a stale viewport offset after a shrink. + const { columns, rows } = this.state.terminal; + const overflowsViewport = this.state.ui.render(columns).length >= rows; + // Force a full re-render after replacing a tall panel with the shorter editor: + // differential rendering leaves the editor shifted up when the bottom-anchored + // region shrinks in place. Skip under tmux (its own reflow handles the shrink) + // and when content fits on one screen (a full clear would pull the editor up). + this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); } restoreInputText(text: string): void { @@ -1742,7 +2632,6 @@ export class KimiTUI { plan, sourceHome: plan.sourceHome, targetHome: this.harness.homeDir, - colors: this.state.theme.colors, skipDecisionStep: this.migrateOnly, requestRender: () => { this.state.ui.requestRender(); @@ -1758,11 +2647,7 @@ export class KimiTUI { // Persist the skip marker `detectPendingMigration` checks, so "Never ask // again" actually stops the prompt from reappearing every launch. try { - writeFileSync( - join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), - '', - 'utf-8', - ); + writeFileSync(join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), '', 'utf-8'); } catch { // Non-blocking: a failed marker write must never crash startup. } @@ -1775,7 +2660,6 @@ export class KimiTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - colors: this.state.theme.colors, onClose: () => { this.hideHelpPanel(); }, @@ -1788,62 +2672,151 @@ export class KimiTUI { this.restoreEditor(); } + private sessionPickerOptions: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + } = { + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, + }; + private sessionPickerScopeRequestToken = 0; + async showSessionPicker(): Promise<void> { - await this.fetchSessions(); - this.mountSessionPicker(() => { - this.hideSessionPicker(); + await this.openSessionPicker({ + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, }); } private async bootstrapFromPicker(): Promise<void> { - await this.fetchSessions(); - this.mountSessionPicker( - () => { + await this.openSessionPicker({ + applyStartupModes: true, + closeOnCancel: true, + forwardEditorExit: true, + }); + } + + private async openSessionPicker(options: { + readonly applyStartupModes: boolean; + readonly closeOnCancel: boolean; + readonly forwardEditorExit: boolean; + }): Promise<void> { + this.sessionPickerOptions = options; + await this.fetchSessions('cwd'); + this.mountSessionPicker({ + applyStartupModes: options.applyStartupModes, + onCancel: () => { this.hideSessionPicker(); - void this.stop(); + if (options.closeOnCancel) void this.stop(); }, - { - onCtrlC: () => { - this.state.editor.onCtrlC?.(); - }, - onCtrlD: () => { - this.state.editor.onCtrlD?.(); - }, + onCtrlC: options.forwardEditorExit + ? () => { + this.state.editor.onCtrlC?.(); + } + : undefined, + onCtrlD: options.forwardEditorExit + ? () => { + this.state.editor.onCtrlD?.(); + } + : undefined, + }); + } + + private async toggleSessionPickerScope(selectedSessionId: string): Promise<void> { + const requestToken = ++this.sessionPickerScopeRequestToken; + const nextScope = this.state.sessionsScope === 'cwd' ? 'all' : 'cwd'; + await this.fetchSessions(nextScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.mountSessionPicker({ + initialSelectedSessionId: selectedSessionId, + applyStartupModes: this.sessionPickerOptions.applyStartupModes, + onCancel: () => { + this.hideSessionPicker(); + if (this.sessionPickerOptions.closeOnCancel) void this.stop(); }, - ); + onCtrlC: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlC?.(); + } + : undefined, + onCtrlD: this.sessionPickerOptions.forwardEditorExit + ? () => { + this.state.editor.onCtrlD?.(); + } + : undefined, + }); } hideSessionPicker(): void { + this.sessionPickerScopeRequestToken += 1; this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); } - private mountSessionPicker( - onCancel: () => void, - shortcuts: { readonly onCtrlC?: () => void; readonly onCtrlD?: () => void } = {}, - ): void { + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + + private mountSessionPicker(options: { + readonly onCancel: () => void; + readonly onCtrlC?: () => void; + readonly onCtrlD?: () => void; + readonly initialSelectedSessionId?: string; + // CLI mode flags (--auto/--yolo/--plan) target the session picked at + // startup (bare --session); later /sessions switches keep the picked + // session's own persisted modes. + readonly applyStartupModes?: boolean; + }): void { this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( new SessionPickerComponent({ sessions: this.state.sessions, loading: this.state.loadingSessions, currentSessionId: this.state.appState.sessionId, - colors: this.state.theme.colors, - onSelect: (sessionId: string) => { - void this.resumeSession(sessionId).then((switched) => { - if (switched) { - this.hideSessionPicker(); - } - }); + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: 50, + onSelect: (session: SessionRow) => { + void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ); + }, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); }, - onCancel, - onCtrlC: shortcuts.onCtrlC, - onCtrlD: shortcuts.onCtrlD, }), ); } + private async handleSessionPickerSelect( + session: SessionRow, + applyStartupModes: boolean, + ): Promise<void> { + if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) { + await this.showResumeOtherWorkDirHint(session); + if (applyStartupModes) await this.stop(0); + return; + } + + const switched = await this.resumeSession(session.id); + if (!switched) return; + if (applyStartupModes) { + await this.applyStartupModesToResumedSession(this.requireSession()); + this.applyStartupPermissionAndPlanToAppState(); + } + this.hideSessionPicker(); + } + private showApprovalPanel(payload: ApprovalPanelData): void { this.patchLivePane({ pendingApproval: { data: payload } }); notifyTerminalOnce(this.state, `approval:${payload.id}`, { @@ -1855,7 +2828,6 @@ export class KimiTUI { (response: ApprovalPanelResponse) => { this.approvalController.respond(adaptPanelResponse(response)); }, - this.state.theme.colors, () => { this.toggleToolOutputExpansion(); }, @@ -1887,7 +2859,6 @@ export class KimiTUI { const viewer = new ApprovalPreviewViewer( { block, - colors: this.state.theme.colors, onClose: () => { this.closeApprovalPreview(); }, @@ -1924,8 +2895,7 @@ export class KimiTUI { (response) => { this.questionController.respond(response); }, - this.state.theme.colors, - undefined, + 6, () => { this.toggleToolOutputExpansion(); }, @@ -1937,5 +2907,4 @@ export class KimiTUI { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - } diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index a17e60586..8112534a0 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -1,6 +1,7 @@ import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ @@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string { switch (display.kind) { case 'plan_review': return ''; + case 'goal_start': + return 'Start a goal?'; case 'generic': if (typeof display.detail === 'string' && display.detail.length > 0) { return display.detail; @@ -320,6 +323,13 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { ]; case 'plan_review': return []; + case 'goal_start': { + const lines = [`Start goal: ${display.objective}`]; + if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) { + lines.push(`Done when: ${display.completionCriterion}`); + } + return [{ type: 'brief', text: lines.join('\n') }]; + } case 'generic': return []; case 'todo_list': @@ -335,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { return adaptPlanReviewChoices(display); } + if (display.kind === 'goal_start') { + return adaptGoalStartChoices(display); + } return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); } +function adaptGoalStartChoices( + display: Extract<ToolInputDisplay, { kind: 'goal_start' }>, +): ApprovalPanelChoice[] { + // Reuse the exact options the /goal start menu shows. Each mode option starts + // the goal under that permission mode (the policy reads selected_label); "Do + // not start" declines so no goal is created. + return goalStartOptions(display.mode).map((option) => + option.value === 'cancel' + ? { + label: option.label, + response: 'cancelled', + selected_label: 'cancel', + description: option.description, + } + : { + label: option.label, + response: 'approved', + selected_label: option.value, + description: option.description, + }, + ); +} + function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { const optionChoices = display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/reverse-rpc/types.ts index c23c938f0..2a41f0df2 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/types.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/types.ts @@ -103,6 +103,9 @@ export interface ApprovalPanelChoice { response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; selected_label?: string | undefined; requires_feedback?: boolean | undefined; + // Optional helper text shown dim beneath the label. Omitted/empty renders + // exactly as a plain label-only choice. + description?: string | undefined; } // ── Approval / Question view payloads ──────────────────────────────── diff --git a/apps/kimi-code/src/tui/theme/bundle.ts b/apps/kimi-code/src/tui/theme/bundle.ts deleted file mode 100644 index 8cfd0e921..000000000 --- a/apps/kimi-code/src/tui/theme/bundle.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MarkdownTheme } from '@earendil-works/pi-tui'; - -import { getColorPalette, type ColorPalette, type ResolvedTheme } from './colors'; -import { createMarkdownTheme } from './pi-tui-theme'; -import { createThemeStyles, type ThemeStyles } from './styles'; -import { resolveThemeSync, type Theme } from './index'; - -export interface KimiTUIThemeBundle { - resolvedTheme: ResolvedTheme; - colors: ColorPalette; - styles: ThemeStyles; - markdownTheme: MarkdownTheme; -} - -export function createKimiTUIThemeBundle( - theme: Theme, - resolvedTheme?: ResolvedTheme, -): KimiTUIThemeBundle { - const actualTheme = resolvedTheme ?? resolveThemeSync(theme); - const colors = { ...getColorPalette(actualTheme) }; - return { - resolvedTheme: actualTheme, - colors, - styles: createThemeStyles(colors), - markdownTheme: createMarkdownTheme(colors), - }; -} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts index 4a9b37bad..66f9819c3 100644 --- a/apps/kimi-code/src/tui/theme/colors.ts +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -1,148 +1,141 @@ /** * Color palette definitions for dark and light themes. * - * Two layers: - * - private `dark` / `light` raw palettes — unsemantic constants reused - * across multiple semantic tokens to avoid hex literal duplication. - * - exported `darkColors` / `lightColors` — the semantic `ColorPalette` - * consumed by every UI component via chalk.hex(...). + * `darkColors` / `lightColors` are the semantic `ColorPalette` consumed by + * every UI component via the global Theme singleton. Each token holds its hex + * value directly — see the per-token docs on `ColorPalette` for what each one + * controls. * * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF * for text tokens and ≥ 3:1 for chrome (border / large text), matching * WCAG AA. */ -const dark = { - blue400: '#4FA8FF', - cyan400: '#5BC0BE', - gray50: '#F5F5F5', - gray100: '#E0E0E0', - gray500: '#888888', - gray600: '#6B6B6B', - gray700: '#5A5A5A', - green400: '#4EC87E', - green300: '#7AD99B', - red400: '#E85454', - red300: '#F08585', - amber400: '#E8A838', - orange300: '#FFCB6B', -} as const; - -const light = { - blue600: '#1565C0', - cyan700: '#00838F', - gray900: '#1A1A1A', - gray700: '#454545', - gray600: '#5F5F5F', - gray500: '#737373', - green700: '#0E7A38', - red700: '#B91C1C', - amber800: '#92660A', - orange700: '#9A4A00', -} as const; - +// Each token below documents where it is actually consumed, so theme authors +// know what changing it affects. "Widely" means the token is read across most +// dialogs/messages rather than in one specific place. export interface ColorPalette { - // Brand + // ── Brand ── + /** Dominant interactive/brand colour: links & inline code, the selected item + * in nearly every dialog, the focused editor border, plan/"running" badges, + * spinners. The most widely used token. */ primary: string; + /** Secondary highlight: approval "▶" prefix, device-code box, image + * placeholder, BTW / queue panes, custom-registry import. */ accent: string; - // Text + // ── Text ── + /** Default body text: dialog bodies, todo titles, footer model label, + * markdown headings, tool/read output, and assistant-side message bullets + * (assistant / tool / agent / read) plus markdown list bullets. */ text: string; + /** Emphasised / bold text: input dialogs, status messages. */ textStrong: string; + /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, + * hints, descriptions, completed todos, markdown quotes, and the footer + * status bar (cwd path, git badge). */ textDim: string; + /** Faintest text: counters, scroll info, descriptions, markdown link URLs, + * code-block borders. */ textMuted: string; - // Surface + // ── Surface ── + /** Borders: pane & editor borders, markdown horizontal rule. */ border: string; + /** Focus / attention border — currently only the approval panel. */ borderFocus: string; - // State + // ── State ── + /** Success: ✓ marks, "enabled", completed states. */ success: string; + /** Warning: auto/yolo badges, stale markers, plan-mode hint. */ warning: string; + /** Error: error messages, failed tool output. */ error: string; - // Diff + // ── Diff (all consumed by components/media/diff-preview.ts) ── + /** Added lines. */ diffAdded: string; + /** Removed lines. */ diffRemoved: string; + /** Added lines — intra-line changed words (bold). */ diffAddedStrong: string; + /** Removed lines — intra-line changed words (bold). */ diffRemovedStrong: string; + /** Line-number gutter (also approval panel/preview). */ diffGutter: string; + /** Meta / hunk headers. */ diffMeta: string; - // Roles + // ── Roles ── + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; - roleAssistant: string; - roleThinking: string; - roleTool: string; - // Status - status: string; + // ── 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 = { - primary: dark.blue400, - accent: dark.cyan400, + primary: '#4FA8FF', + accent: '#5BC0BE', - text: dark.gray100, - textStrong: dark.gray50, - textDim: dark.gray500, - textMuted: dark.gray600, + text: '#E0E0E0', + textStrong: '#F5F5F5', + textDim: '#888888', + textMuted: '#6B6B6B', - border: dark.gray700, - borderFocus: dark.amber400, + border: '#5A5A5A', + borderFocus: '#E8A838', - success: dark.green400, - warning: dark.amber400, - error: dark.red400, + success: '#4EC87E', + warning: '#E8A838', + error: '#E85454', - diffAdded: dark.green400, - diffRemoved: dark.red400, - diffAddedStrong: dark.green300, - diffRemovedStrong: dark.red300, - diffGutter: dark.gray600, - diffMeta: dark.gray500, + diffAdded: '#4EC87E', + diffRemoved: '#E85454', + diffAddedStrong: '#7AD99B', + diffRemovedStrong: '#F08585', + diffGutter: '#6B6B6B', + diffMeta: '#888888', - roleUser: dark.orange300, - roleAssistant: dark.gray100, - roleThinking: dark.gray500, - roleTool: dark.amber400, - - status: dark.gray500, + roleUser: '#FFCB6B', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { - primary: light.blue600, - accent: light.cyan700, + primary: '#1565C0', + accent: '#00838F', - text: light.gray900, - textStrong: light.gray900, - textDim: light.gray700, - textMuted: light.gray600, + text: '#1A1A1A', + textStrong: '#1A1A1A', + textDim: '#454545', + textMuted: '#5F5F5F', - border: light.gray500, - borderFocus: light.amber800, + border: '#737373', + borderFocus: '#92660A', - success: light.green700, - warning: light.amber800, - error: light.red700, + success: '#0E7A38', + warning: '#92660A', + error: '#B91C1C', - diffAdded: light.green700, - diffRemoved: light.red700, - diffAddedStrong: light.green700, - diffRemovedStrong: light.red700, - diffGutter: light.gray500, - diffMeta: light.gray600, + diffAdded: '#0E7A38', + diffRemoved: '#B91C1C', + diffAddedStrong: '#0E7A38', + diffRemovedStrong: '#B91C1C', + diffGutter: '#737373', + diffMeta: '#5F5F5F', - roleUser: light.orange700, - roleAssistant: light.gray900, - roleThinking: light.gray700, - roleTool: light.amber800, - - status: light.gray700, + roleUser: '#9A4A00', + shellMode: '#7C3AED', }; export type ResolvedTheme = 'dark' | 'light'; -export function getColorPalette(theme: ResolvedTheme): ColorPalette { - return theme === 'dark' ? darkColors : lightColors; +/** Synchronous palette lookup for built-in themes only. */ +export function getBuiltInPalette(resolved: ResolvedTheme): ColorPalette { + return resolved === 'dark' ? darkColors : lightColors; } diff --git a/apps/kimi-code/src/tui/theme/custom-theme-loader.ts b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts new file mode 100644 index 000000000..cc0b07cbf --- /dev/null +++ b/apps/kimi-code/src/tui/theme/custom-theme-loader.ts @@ -0,0 +1,97 @@ +/** + * Custom theme loader — reads JSON files from `~/.kimi-code/themes/`. + */ + +import { readdirSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; + +export const CustomThemeSchema = z.object({ + name: z.string().min(1), + displayName: z.string().optional(), + /** Built-in palette that unspecified tokens fall back to. Defaults to `dark`. */ + base: z.enum(['dark', 'light']).optional(), + colors: z.record(z.string(), z.string()).optional(), +}); + +export type CustomThemeDefinition = z.infer<typeof CustomThemeSchema>; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +/** + * Names reserved for built-in themes. A `dark.json` / `light.json` / + * `auto.json` file would collide with the built-in value, so it can never be + * selected as a custom theme — hide it from listings. + */ +const RESERVED_THEME_NAMES: ReadonlySet<string> = new Set(['dark', 'light', 'auto']); + +export function getCustomThemesDir(): string { + return join(getDataDir(), 'themes'); +} + +interface ParsedCustomTheme { + readonly base: ResolvedTheme; + readonly colors: Partial<ColorPalette>; +} + +async function readCustomTheme(name: string): Promise<ParsedCustomTheme | null> { + try { + const content = await readFile(join(getCustomThemesDir(), `${name}.json`), 'utf-8'); + const parsed = CustomThemeSchema.parse(JSON.parse(content)); + + // Invalid hex values are dropped (the token falls back to the base + // palette). We intentionally do not print here: this loader can run while + // pi-tui owns the terminal, where raw stdout/stderr writes corrupt the + // rendered screen. Authoring-time validation lives in the JSON schema. + const colors = Object.fromEntries( + Object.entries(parsed.colors ?? {}).filter(([, v]) => HEX_COLOR_REGEX.test(v)), + ) as Partial<ColorPalette>; + + return { base: parsed.base ?? 'dark', colors }; + } catch { + return null; + } +} + +export async function loadCustomTheme(name: string): Promise<Partial<ColorPalette> | null> { + return (await readCustomTheme(name))?.colors ?? null; +} + +/** Load a custom theme and merge it onto its base palette (dark unless `base` says otherwise). */ +export async function loadCustomThemeMerged(name: string): Promise<ColorPalette | null> { + const parsed = await readCustomTheme(name); + if (parsed === null) return null; + return { ...getBuiltInPalette(parsed.base), ...parsed.colors }; +} + +function toThemeNames(files: readonly string[]): string[] { + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => !RESERVED_THEME_NAMES.has(name)); +} + +export async function listCustomThemes(): Promise<string[]> { + try { + const entries = await readdir(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} + +/** Synchronous variant for UI paths (e.g. the `/theme` picker) that cannot await. */ +export function listCustomThemesSync(): string[] { + try { + const entries = readdirSync(getCustomThemesDir(), { withFileTypes: true }); + return toThemeNames(entries.filter((e) => e.isFile()).map((e) => e.name)); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index 5cd97384f..e016def5d 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -2,46 +2,62 @@ * Theme system public API. */ -import type { ResolvedTheme } from './colors'; +import { getBuiltInPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; +import { loadCustomThemeMerged } from './custom-theme-loader'; import { detectTerminalTheme } from './detect'; -export { darkColors, lightColors, getColorPalette } from './colors'; +export { currentTheme, Theme } from './theme'; +export type { ColorToken } from './theme'; +export { darkColors, lightColors, getBuiltInPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; -export { createThemeStyles } from './styles'; -export type { ThemeStyles } from './styles'; -export { gradientText } from './gradient-text'; -export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; +export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; /** - * User-facing theme preference. `'auto'` defers to terminal background - * detection at startup; `'dark'` / `'light'` are explicit overrides that - * never trigger detection. The persisted value in `tui.toml` is always - * one of these three; the detected `ResolvedTheme` is computed at - * startup and held only in memory. + * User-facing theme preference. + * `'auto'` defers to terminal background detection at startup. + * `'dark'` / `'light'` are explicit built-in overrides. + * Any other string is treated as a custom theme name looked up in + * `~/.kimi-code/themes/<name>.json`. */ -export type Theme = 'dark' | 'light' | 'auto'; +export type BuiltInTheme = 'dark' | 'light' | 'auto'; +export type ThemeName = BuiltInTheme | (string & {}); -export function isTheme(value: string): value is Theme { +export function isBuiltInTheme(value: string): value is BuiltInTheme { return value === 'dark' || value === 'light' || value === 'auto'; } -/** - * Resolve a user preference to a concrete palette key. `'auto'` triggers - * terminal background detection (OSC 11 with COLORFGBG / dark fallback); - * explicit choices pass through. - */ -export async function resolveTheme(theme: Theme): Promise<ResolvedTheme> { - if (theme === 'auto') return detectTerminalTheme(); - return theme; +export function isThemeName(_value: string): _value is ThemeName { + return true; // any string is a valid theme name (custom themes) } /** - * Synchronous fallback used by paths that cannot wait on terminal probes - * (initial state construction, in-TUI theme switches). `'auto'` collapses - * to `'dark'`; explicit choices pass through. + * Resolve a user preference to a concrete palette. + * + * - `'auto'` triggers terminal background detection. + * - `'dark'` / `'light'` return the built-in palette. + * - Any other string loads a custom theme from `~/.kimi-code/themes/`; + * missing / invalid files fall back to dark palette. */ -export function resolveThemeSync(theme: Theme): ResolvedTheme { - if (theme === 'auto') return 'dark'; - return theme; +export async function getColorPalette(theme: ThemeName): Promise<ColorPalette> { + if (theme === 'light') return getBuiltInPalette('light'); + if (theme === 'dark') return getBuiltInPalette('dark'); + if (theme === 'auto') { + const detected = await detectTerminalTheme(); + return getBuiltInPalette(detected); + } + // custom theme + const custom = await loadCustomThemeMerged(theme); + return custom ?? getBuiltInPalette('dark'); +} + +/** + * Synchronous fallback used by paths that cannot wait on terminal probes. + * `'auto'` collapses to `'dark'`; explicit choices pass through. + * Custom themes are not supported here — falls back to dark. + */ +export function getColorPaletteSync(theme: ThemeName): ColorPalette { + if (theme === 'light') return getBuiltInPalette('light'); + return getBuiltInPalette('dark'); } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index dc3b1b9ad..1b53bce0c 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -1,15 +1,18 @@ /** - * Pi-tui theme adapters — MarkdownTheme and EditorTheme from our ColorPalette. + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. * - * All chalk calls route through `ColorPalette` tokens so themes flip - * cleanly. No raw `chalk.gray` / `chalk.dim` / `chalk.white` here. + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. */ -import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; +import type { MarkdownTheme, EditorTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; -import type { ColorPalette } from './colors'; +import { currentTheme } from './theme'; // pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 // headings (h1/h2 are rendered without the `#` prefix). The prefix arrives @@ -19,30 +22,31 @@ import type { ColorPalette } from './colors'; // eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; -export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - const muted = chalk.hex(colors.textMuted); - const dim = chalk.hex(colors.textDim); - const border = chalk.hex(colors.border); + return { - heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)), - link: (text) => chalk.hex(colors.primary)(text), - linkUrl: (text) => muted(text), - code: (text) => chalk.hex(colors.primary)(text), + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), codeBlock: (text) => text, - codeBlockBorder: (text) => muted(text), - quote: (text) => dim(text), - quoteBorder: (text) => dim(text), - hr: (text) => border(text), + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), // Match the assistant-message bullet so list markers read like a reply - // prefix. Ordered lists arrive as `"1. "` / `"2. "` and are left + // prefix. Ordered lists arrive as "1. " / "2. " and are left // untouched by the leading-dash anchor. - listBullet: (text) => chalk.hex(colors.roleAssistant)(text.replace(/^-/, '•')), + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), bold: (text) => chalk.bold(text), italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), 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'; @@ -56,16 +60,15 @@ export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { }; } -export function createEditorTheme(colors: ColorPalette): EditorTheme { - const muted = chalk.hex(colors.textMuted); +export function createEditorTheme(): EditorTheme { return { - borderColor: (s) => chalk.hex(colors.border)(s), + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), selectList: { - selectedPrefix: (s) => chalk.hex(colors.primary)(s), - selectedText: (s) => chalk.hex(colors.primary)(s), - description: (s) => muted(s), - scrollInfo: (s) => muted(s), - noMatch: (s) => muted(s), + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), }, }; } diff --git a/apps/kimi-code/src/tui/theme/styles.ts b/apps/kimi-code/src/tui/theme/styles.ts deleted file mode 100644 index 625302e45..000000000 --- a/apps/kimi-code/src/tui/theme/styles.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Theme-aware style helpers built on chalk. Components hold a reference - * to a `ThemeStyles` instance via `state.theme.styles` and never reach into - * raw chalk color names — that keeps theme switches consistent and lets - * every visual token route through `ColorPalette`. - */ - -import chalk from 'chalk'; - -import type { ColorPalette } from './colors'; - -export interface ThemeStyles { - colors: ColorPalette; - - /** Brand primary (links, focus, slash highlight). */ - primary(text: string): string; - /** Secondary brand accent (command operators, approval labels). */ - accent(text: string): string; - /** Dimmed text — secondary but still readable. */ - dim(text: string): string; - /** Muted text — most faded; for unchanged-line counters, scroll info. */ - muted(text: string): string; - /** Body text — same color as default but explicit for theming. */ - text(text: string): string; - /** Strong / emphasized text — paths, URLs, command bodies. */ - strong(text: string): string; - - error(text: string): string; - warning(text: string): string; - success(text: string): string; - - /** Bold + dim, for label cells. */ - label(text: string): string; - /** Body color, for value cells. */ - value(text: string): string; - - diffAdd(text: string): string; - diffDel(text: string): string; - diffAddBold(text: string): string; - diffDelBold(text: string): string; - diffGutter(text: string): string; - diffMeta(text: string): string; -} - -export function createThemeStyles(colors: ColorPalette): ThemeStyles { - return { - colors, - primary: (s) => chalk.hex(colors.primary)(s), - accent: (s) => chalk.hex(colors.accent)(s), - dim: (s) => chalk.hex(colors.textDim)(s), - muted: (s) => chalk.hex(colors.textMuted)(s), - text: (s) => chalk.hex(colors.text)(s), - strong: (s) => chalk.hex(colors.textStrong)(s), - error: (s) => chalk.hex(colors.error)(s), - warning: (s) => chalk.hex(colors.warning)(s), - success: (s) => chalk.hex(colors.success)(s), - label: (s) => chalk.bold.hex(colors.textDim)(s), - value: (s) => chalk.hex(colors.text)(s), - diffAdd: (s) => chalk.hex(colors.diffAdded)(s), - diffDel: (s) => chalk.hex(colors.diffRemoved)(s), - diffAddBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), - diffDelBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), - diffGutter: (s) => chalk.hex(colors.diffGutter)(s), - diffMeta: (s) => chalk.hex(colors.diffMeta)(s), - }; -} diff --git a/apps/kimi-code/src/tui/theme/theme-schema.json b/apps/kimi-code/src/tui/theme/theme-schema.json new file mode 100644 index 000000000..a411088f9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme-schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/moonshot-ai/kimi-code/blob/main/apps/kimi-code/src/tui/theme/theme-schema.json", + "title": "Kimi Code Custom Theme", + "description": "Schema for Kimi Code TUI custom theme definitions", + "type": "object", + "required": ["name"], + "properties": { + "$schema": { + "type": "string", + "description": "URL to this JSON Schema" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Machine-readable theme identifier (kebab-case recommended)" + }, + "displayName": { + "type": "string", + "description": "Human-readable theme name shown in UI" + }, + "base": { + "type": "string", + "enum": ["dark", "light"], + "description": "Built-in palette that unspecified tokens fall back to (default: dark)" + }, + "colors": { + "type": "object", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, + "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, + "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, + "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, + "textMuted": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Most faded text; for counters, scroll info" }, + "border": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Border color" }, + "borderFocus": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Focused border color" }, + "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, + "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, + "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, + "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, + "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, + "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, + "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, + "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, + "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, + "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } + }, + "additionalProperties": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Any valid ColorPalette token" + } + } + }, + "additionalProperties": false +} diff --git a/apps/kimi-code/src/tui/theme/theme.ts b/apps/kimi-code/src/tui/theme/theme.ts new file mode 100644 index 000000000..3b7a377fe --- /dev/null +++ b/apps/kimi-code/src/tui/theme/theme.ts @@ -0,0 +1,93 @@ +/** + * Theme class + global singleton. + * + * Components import `currentTheme` and call methods like + * `currentTheme.fg('primary', text)` at render time. When the user switches + * themes we call `currentTheme.setPalette(newPalette)` — the same singleton + * instance stays alive, so every component (including already-rendered + * transcript entries) sees the new colours on the next render frame. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; +import { darkColors } from './colors'; + +export type ColorToken = keyof ColorPalette; + +export class Theme { + private _palette: ColorPalette; + + constructor(palette: ColorPalette) { + this._palette = palette; + } + + get palette(): ColorPalette { + return this._palette; + } + + setPalette(palette: ColorPalette): void { + this._palette = palette; + } + + color(token: ColorToken): string { + return this._palette[token]; + } + + /* ── Foreground helpers ── */ + + fg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token])(text); + } + + boldFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).bold(text); + } + + dimFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).dim(text); + } + + italicFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).italic(text); + } + + underlineFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).underline(text); + } + + strikethroughFg(token: ColorToken, text: string): string { + return chalk.hex(this._palette[token]).strikethrough(text); + } + + /* ── Background helpers ── */ + + bg(token: ColorToken, text: string): string { + return chalk.bgHex(this._palette[token])(text); + } + + /* ── Standalone style helpers ── */ + + bold(text: string): string { + return chalk.bold(text); + } + + dim(text: string): string { + return chalk.dim(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } +} + +/** Global singleton. Initialise with dark palette; switch via `setPalette`. */ +export const currentTheme = new Theme(darkColors); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index a3bf88c24..d9665c9e3 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,7 +2,7 @@ import { Container, ProcessTerminal, TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; @@ -10,9 +10,10 @@ import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; +import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; -import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { currentTheme, type Theme } from './theme'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -36,7 +37,7 @@ export interface TUIState { editorContainer: Container; footer: FooterComponent; editor: CustomEditor; - theme: KimiTUIThemeBundle; + theme: Theme; appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; @@ -46,16 +47,24 @@ export interface TUIState { toolOutputExpanded: boolean; sessions: SessionRow[]; loadingSessions: boolean; + sessionsScope: 'cwd' | 'all'; activeDialog: 'session-picker' | 'help' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; + /** + * True while a queued user message has been shifted out of + * {@link queuedMessages} but its deferred send has not run yet. The queue + * looks empty during this window, so queued-goal promotion must also check + * this flag to avoid starting a goal ahead of the user's earlier message. + */ + queuedMessageDispatchPending: boolean; swarmModeEntry: 'manual' | 'task' | undefined; } export function createTUIState(options: KimiTUIOptions): TUIState { const initialAppState = options.initialAppState; - const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + const theme = currentTheme; const terminal = new ProcessTerminal(); const ui = new TUI(terminal); @@ -63,12 +72,14 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanel = new TodoPanelComponent(theme.colors); + const todoPanel = new TodoPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui, theme.colors); - const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); + const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); @@ -82,8 +93,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { queueContainer, btwPanelContainer, editorContainer, - footer, editor, + footer, theme, appState: { ...initialAppState }, startupState: 'pending', @@ -94,10 +105,12 @@ export function createTUIState(options: KimiTUIOptions): TUIState { toolOutputExpanded: false, sessions: [], loadingSessions: false, + sessionsScope: 'cwd', activeDialog: null, tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], + queuedMessageDispatchPending: false, swarmModeEntry: undefined, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 3c65fa67f..6dcdccdd1 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -5,32 +5,51 @@ import type { PermissionMode, ProviderConfig, PromptPart, + ThinkingEffort, ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; -import type { Theme } from './theme'; -import type { ResolvedTheme } from './theme/colors'; +import type { ColorToken, ThemeName } from './theme'; + +export type BannerDisplay = 'always' | 'once' | 'cooldown'; + +export interface BannerState { + key: string; + tag: string | null; + mainText: string; + subText: string | null; + display: BannerDisplay; + ttlHours?: number; +} export interface AppState { model: string; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; swarmMode: boolean; - thinking: boolean; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; - theme: Theme; + theme: ThemeName; version: string; editorCommand: string | null; + /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ + disablePasteBurst?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; availableModels: Record<string, ModelAlias>; @@ -39,6 +58,8 @@ export interface AppState { /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; mcpServersSummary: string | null; + /** Optional banner shown below the welcome panel; null means no banner to render. */ + banner?: BannerState | null; } export interface ToolCallBlockData { @@ -97,6 +118,8 @@ export interface BackgroundAgentStatusData { } export interface CompactionTranscriptData { + readonly result?: 'cancelled'; + readonly summary?: string; readonly tokensBefore?: number; readonly tokensAfter?: number; readonly instruction?: string; @@ -123,19 +146,30 @@ 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; turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; - color?: 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; @@ -146,6 +180,7 @@ export interface TranscriptEntry { skillName?: string; skillArgs?: string; skillTrigger?: SkillActivationTrigger; + pluginCommandData?: PluginCommandTranscriptData; } export type LivePaneMode = @@ -166,6 +201,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 = { @@ -193,7 +231,6 @@ export type TUIStartupState = 'pending' | 'ready' | 'picker'; export interface KimiTUIOptions { initialAppState: AppState; startup: TUIStartupOptions; - resolvedTheme?: ResolvedTheme; } export interface PendingExit { @@ -203,6 +240,7 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/utils/foreground-task.ts b/apps/kimi-code/src/tui/utils/foreground-task.ts new file mode 100644 index 000000000..ba2ac811b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/foreground-task.ts @@ -0,0 +1,32 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { + return ( + t.detached === false && + t.status === 'running' && + (t.kind === 'process' || t.kind === 'agent') + ); +} + +/** + * Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`, + * currently-running Bash (`process`) or subagent (`agent`) tasks, most recently + * started first. + */ +export function pickForegroundTasks( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo[] { + return tasks + .filter(isDetachableForegroundTask) + .sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * Pick the single most recently started foreground task. Kept for callers that + * only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all. + */ +export function pickForegroundTask( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo | undefined { + return pickForegroundTasks(tasks)[0]; +} diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts index bac4ab8fb..8d653159a 100644 --- a/apps/kimi-code/src/tui/utils/image-attachment-store.ts +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -6,7 +6,9 @@ * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the * user sees in the input field; on submit, `extractMediaAttachments` * walks the text and expands image placeholders to image content parts - * and video placeholders to file-path tags for `ReadMediaFile`. + * (preceded by a compression caption when paste-time compression shrank + * the bytes — see `ImageAttachment.original`) and video placeholders to + * file-path tags for `ReadMediaFile`. * * Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`, * session switch) call `clear()` so ids restart from 1 and stale @@ -15,6 +17,18 @@ * `--resume` wouldn't know how to materialize the files anyway. */ +export interface ImageAttachmentOriginal { + /** + * Where the pre-compression bytes were persisted for readback + * (ReadMediaFile + region); null when persistence failed. + */ + readonly path: string | null; + readonly width: number; + readonly height: number; + readonly byteLength: number; + readonly mime: string; +} + export interface ImageAttachment { readonly id: number; readonly kind: 'image'; @@ -22,6 +36,12 @@ export interface ImageAttachment { readonly mime: string; readonly width: number; readonly height: number; + /** + * Pre-compression original, recorded when paste-time compression changed + * the bytes. Drives the compression caption emitted on submit so the model + * knows it received a downsampled copy. Absent for untouched pastes. + */ + readonly original?: ImageAttachmentOriginal | undefined; /** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */ readonly placeholder: string; } @@ -43,7 +63,13 @@ export class ImageAttachmentStore { private nextId = 1; private readonly byId = new Map<number, MediaAttachment>(); - addImage(bytes: Uint8Array, mime: string, width: number, height: number): ImageAttachment { + addImage( + bytes: Uint8Array, + mime: string, + width: number, + height: number, + original?: ImageAttachmentOriginal, + ): ImageAttachment { const id = this.nextId; this.nextId += 1; const attachment: ImageAttachment = { @@ -53,6 +79,7 @@ export class ImageAttachmentStore { mime, width, height, + original, placeholder: formatPlaceholder(id, width, height), }; this.byId.set(id, attachment); @@ -88,6 +115,19 @@ export class ImageAttachmentStore { this.nextId = 1; } + /** + * Drop a single attachment, releasing its bytes. Used to reclaim image + * memory once the transcript entry that references it is trimmed. + */ + remove(id: number): void { + this.byId.delete(id); + } + + /** Drop many attachments at once. See {@link remove}. */ + removeMany(ids: Iterable<number>): void { + for (const id of ids) this.byId.delete(id); + } + size(): number { return this.byId.size; } diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 11c401f2f..4017c4b2d 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -8,14 +8,23 @@ * the text (we can't hallucinate files for it). * - Order is preserved for text/image/video segments. Image placeholders * expand to image content parts so the prompt reaches the provider - * without relying on a model tool call. Video placeholders still expand - * to file-path tags so `ReadMediaFile` can own video upload behavior. + * without relying on a model tool call. Video placeholders are copied + * into the shared cache (`getCacheDir()`) and expand to file-path tags, + * so `ReadMediaFile` — and the provider's `VideoUploader` — own video + * upload behavior instead of base64-inlining here. * - Adjacent text segments are flattened — empty / whitespace-only * segments drop out so we never emit `{type:'text', text:' '}` * noise between two media parts. */ +import { randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; +import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk'; + +import { getCacheDir } from '#/utils/paths'; import type { ImageAttachment, @@ -62,10 +71,15 @@ export function extractMediaAttachments( const before = text.slice(cursor, match.index); pushText(parts, before); if (attachment.kind === 'video') { - const mediaText = tagTextForVideo(attachment); - pushText(parts, mediaText); + const cachePath = materializeVideoToCache(attachment); + pushText(parts, formatMediaTag('video', cachePath)); videoAttachmentIds.push(id); } else { + // Paste-time compression is announced next to the image so the model + // knows it received a downsampled copy and where the original lives. + if (attachment.original !== undefined) { + pushText(parts, captionForCompressedImage(attachment)); + } parts.push(imagePartForAttachment(attachment)); imageAttachmentIds.push(id); } @@ -109,8 +123,32 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } -function tagTextForVideo(att: VideoAttachment): string { - return formatMediaTag('video', att.sourcePath); +function materializeVideoToCache(att: VideoAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + const target = join(cacheDir, `${randomUUID()}-${att.label}`); + copyFileSync(att.sourcePath, target); + return target; +} + +function captionForCompressedImage(att: ImageAttachment): string { + const original = att.original; + if (original === undefined) return ''; + return buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.byteLength, + mimeType: original.mime, + }, + final: { + width: att.width, + height: att.height, + byteLength: att.bytes.length, + mimeType: att.mime, + }, + originalPath: original.path, + }); } function formatMediaTag(tag: 'image' | 'video', path: string): string { diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index b1478697c..99441472b 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -32,6 +32,7 @@ export interface ReplayRenderContext { toolCalls: Map<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': diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index eaddeae65..d475313ae 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } } +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + const url = new URL(trimmed); + return ( + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/') + ); + } catch { + return false; + } +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts index 7daa36ab6..d165d52a4 100644 --- a/apps/kimi-code/src/tui/utils/printable-key.ts +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -20,7 +20,7 @@ * `tui/components/**` and rejects bare-literal comparisons. */ -import { decodeKittyPrintable } from '@earendil-works/pi-tui'; +import { decodeKittyPrintable } from '@moonshot-ai/pi-tui'; export function printableChar(data: string): string { return decodeKittyPrintable(data) ?? data; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index ab186f562..926b458e1 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -1,21 +1,17 @@ import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - fetchCustomRegistry, - fetchManagedKimiCodeModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - 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>; @@ -23,423 +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 type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; -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 }>; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source as Record<string, unknown>; - 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 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) +/** + * 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> { + return refreshProviderModels( + { + 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, ); } - -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; - config.defaultThinking = 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 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 async function refreshAllProviderModels(host: RefreshProviderHost): Promise<RefreshResult> { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - - 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); - - 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), - }); - } - } - - // ------------------------------------------------------------------------- - // 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); - - 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, apiKey}) - // ------------------------------------------------------------------------- - const customSources = new Map<string, { source: CustomRegistrySource; 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 = `${source.url}${source.apiKey}`; - const entry = customSources.get(key); - if (entry !== undefined) { - entry.providerIds.push(providerId); - } else { - customSources.set(key, { source, providerIds: [providerId] }); - } - } - - for (const { source, providerIds } of customSources.values()) { - try { - const entries = await fetchCustomRegistry(source); - // 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; - }> = []; - - for (const providerId of providerIds) { - const entry = entries[providerId]; - if (entry === undefined) continue; - - applyCustomRegistryProvider(asManaged(next), entry, source); - const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - } - } - - if (changedProviders.length > 0) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - for (const { providerId } of changedProviders) { - 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(change); - } - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; -} diff --git a/apps/kimi-code/src/tui/utils/render-cache.ts b/apps/kimi-code/src/tui/utils/render-cache.ts new file mode 100644 index 000000000..628d80fae --- /dev/null +++ b/apps/kimi-code/src/tui/utils/render-cache.ts @@ -0,0 +1,28 @@ +/** + * Render-cache toggle for TUI message components. + * + * The transcript re-renders the entire component tree on every frame, and + * most message components rebuild their `render(width)` output from scratch + * even when their content has not changed. Caching the rendered lines (keyed + * on width + a dirty flag) turns an unchanged message's render into an O(1) + * array reference return, which is the dominant per-frame cost once the + * transcript grows long. + * + * The cache is on by default and can be disabled with + * `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks + * compare cached vs. uncached runs in the same process). + */ + +let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1'; + +export function isRenderCacheEnabled(): boolean { + return enabled; +} + +/** + * Override the cache at runtime. Intended for benchmarks / tests only; + * production code should not call this. + */ +export function setRenderCacheEnabled(value: boolean): void { + enabled = value; +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index b5b7343a7..00a920e1f 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -8,7 +8,7 @@ * everywhere: ↑/↓, PgUp/PgDn, and search editing. */ -import { fuzzyFilter, Key, matchesKey } from '@earendil-works/pi-tui'; +import { fuzzyFilter, Key, matchesKey } from '@moonshot-ai/pi-tui'; import { pageView, type PageView } from './paging'; import { isPrintableChar, printableChar } from './printable-key'; diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts new file mode 100644 index 000000000..3a482feb7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). + +// ESC [ <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)'; + } +} diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts new file mode 100644 index 000000000..a4b5099a7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -0,0 +1,94 @@ +/** + * Shared tab strip renderer for tabbed dialogs (model selector, plugin + * marketplace, …). The active tab is filled with the brand background, inactive + * tabs are muted — matching the AskUserQuestion dialog. See + * .agents/skills/write-tui/DESIGN.md §5. + * + * When the strip is wider than the terminal, it scrolls to keep the active tab + * visible, framed by `<`/`>` markers. + */ + +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface RenderTabStripOptions { + readonly labels: readonly string[]; + readonly activeIndex: number; + readonly width: number; + readonly colors: ColorPalette; +} + +/** Style one tab cell. Active and inactive cells have the same visible width so + * switching never shifts the layout. */ +function styleTab(label: string, isActive: boolean, colors: ColorPalette): string { + const cell = ` ${label} `; + return isActive + ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) + : chalk.hex(colors.textMuted)(cell); +} + +export function renderTabStrip(opts: RenderTabStripOptions): string { + const { labels, activeIndex, width, colors } = opts; + const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors)); + + // If everything fits with a leading space, show the whole strip. Account for + // the single spaces `segments.join(' ')` inserts between tabs — otherwise the + // strip is declared to fit at widths where the joined line is actually wider + // and gets truncated instead of showing the `<`/`>` scroll markers. + const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); + const fullSeparatorWidth = Math.max(0, segments.length - 1); + if (1 + totalSegmentWidth + fullSeparatorWidth <= width) { + return ' ' + segments.join(' '); + } + + // Scrolling needed. Find the widest window that contains activeIndex. + const segmentWidths = segments.map((s) => visibleWidth(s)); + let start = activeIndex; + let end = activeIndex + 1; + let contentWidth = segmentWidths[activeIndex] ?? 0; + + const fits = (s: number, e: number, cw: number): boolean => { + const needLeft = s > 0; + const needRight = e < segments.length; + const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); + const separators = Math.max(0, e - s - 1); + return cw + separators + frameWidth <= width; + }; + + while (true) { + const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; + const rightW = end < segments.length ? segmentWidths[end]! : Infinity; + if (leftW === Infinity && rightW === Infinity) break; + + if (leftW <= rightW) { + if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else { + break; + } + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else { + break; + } + } + + const hasLeft = start > 0; + const hasRight = end < segments.length; + let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + strip += segments.slice(start, end).join(' '); + if (hasRight) { + strip += chalk.hex(colors.textMuted)(' >'); + } + return strip; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index 2a0247cc7..c71f41df9 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; import type { TUIState } from '#/tui/tui-state'; @@ -110,6 +110,25 @@ export function supportsOsc9Notification(env: NodeJS.ProcessEnv = process.env): return false; } +/** + * Best-effort detection of ConEmu-style OSC 9;4 progress support, driven + * off well-known environment variables like `supportsOsc9Notification`. + * The two allow-lists must stay separate: iTerm2 posts a desktop + * notification for ANY `OSC 9;<payload>` it receives, so sending the 9;4 + * progress sequence there pops a "4;3" notification every keepalive tick. + * Terminals outside this list simply get no progress reporting, which is + * always safe. + */ +export function supportsTerminalProgress(env: NodeJS.ProcessEnv = process.env): boolean { + if ((env['WT_SESSION'] ?? '').length > 0) return true; + if (env['ConEmuANSI'] === 'ON') return true; + const termProgram = env['TERM_PROGRAM'] ?? ''; + if (termProgram === 'ghostty' || termProgram === 'WezTerm') return true; + const term = env['TERM'] ?? ''; + if (term === 'xterm-ghostty') return true; + return false; +} + export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { const tmux = env['TMUX'] ?? ''; return tmux.length > 0; diff --git a/apps/kimi-code/src/tui/utils/terminal-state.ts b/apps/kimi-code/src/tui/utils/terminal-state.ts index 86d2bab9f..29127ea02 100644 --- a/apps/kimi-code/src/tui/utils/terminal-state.ts +++ b/apps/kimi-code/src/tui/utils/terminal-state.ts @@ -1,9 +1,14 @@ -import { isInsideTmux, supportsOsc9Notification } from './terminal-notification'; +import { + isInsideTmux, + supportsOsc9Notification, + supportsTerminalProgress, +} from './terminal-notification'; export interface TerminalState { notificationKeys: Set<string>; focused: boolean; supportsOsc9: boolean; + supportsProgress: boolean; insideTmux: boolean; progressActive: boolean; } @@ -13,6 +18,7 @@ export function createTerminalState(): TerminalState { notificationKeys: new Set<string>(), focused: true, supportsOsc9: supportsOsc9Notification(), + supportsProgress: supportsTerminalProgress(), insideTmux: isInsideTmux(), progressActive: false, }; diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts new file mode 100644 index 000000000..2e8411ef9 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,36 @@ +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; a concrete effort enables thinking + * and records it as the global effort preference. `'on'` is the boolean-model + * on-signal rather than a declared effort, so it only persists `enabled` — + * boolean models resolve back to `'on'` at runtime via `defaultThinkingEffortFor`. + */ +export function thinkingEffortToConfig(effort: ThinkingEffort): { + enabled: boolean; + effort?: string; +} { + if (effort === 'off') return { enabled: false }; + if (effort === 'on') return { enabled: true }; + return { enabled: true, effort }; +} + +/** + * Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort + * to activate a model with from the persisted `[thinking]` config. Returns + * `'off'` when thinking is disabled, the configured concrete effort when set, + * and `undefined` when thinking is enabled without a concrete effort so the + * model's own default applies. + */ +export function thinkingEffortFromConfig( + config: { enabled?: boolean; effort?: string } | undefined, +): ThinkingEffort | undefined { + if (config?.enabled === false) return 'off'; + return config?.effort; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts index 12151958a..94cb45693 100644 --- a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import type { TranscriptEntry } from '../types'; diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts new file mode 100644 index 000000000..ed0083b6f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,109 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 15); + +/** 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', 5); + +/** 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; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 000000000..dc0f60886 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + 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; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + 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'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 000000000..8c344d02e --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,44 @@ +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; +}): Promise<boolean> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + + if (env['TERMUX_VERSION'] !== undefined) return false; + + // 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; + + return hasImageViaNative(clip); +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 0bb8d3c76..6aae761c4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -16,7 +16,6 @@ * supported, or every fallback fails. */ -import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +24,20 @@ import { fileURLToPath } from 'node:url'; import { parseImageMeta } from '#/utils/image/image-mime'; +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; export interface ClipboardImage { @@ -49,14 +62,6 @@ export class ClipboardMediaError extends Error { } } -type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; -type RunCommand = ( - command: string, - args: string[], - options?: RunCommandOptions, -) => { stdout: Buffer; ok: boolean }; - -const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ @@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ '.3g2': 'video/3gpp2', }); -const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; -const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -115,28 +118,6 @@ if (String(pb) !== '[id nil]') { out.join('\n'); `.trim(); -function isWaylandSession(env: NodeJS.ProcessEnv): boolean { - return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; -} - -function isWSL(env: NodeJS.ProcessEnv): boolean { - if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; - try { - return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); - } catch { - return false; - } -} - -function baseMimeType(raw: string): string { - return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); -} - -function isSupportedImageMimeType(mime: string): boolean { - const base = baseMimeType(mime); - return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); -} - function selectPreferredImageMimeType(candidates: string[]): string | null { const normalized = candidates .map((t) => t.trim()) @@ -253,29 +234,11 @@ function readMediaFromText(text: string): ClipboardMedia | null { return readMediaFromPaths(parseClipboardPaths(text)); } -function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -): { stdout: Buffer; ok: boolean } { - const result = spawnSync(command, args, { - timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, - maxBuffer: DEFAULT_MAX_BUFFER_BYTES, +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, env: options?.env, }); - if (result.error !== undefined || result.status !== 0) { - return { ok: false, stdout: Buffer.alloc(0) }; - } - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); - return { ok: true, stdout }; -} - -function parseTargetList(output: Buffer): string[] { - return output - .toString('utf-8') - .split(/\r?\n/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); } function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { @@ -394,28 +357,6 @@ function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { return parseClipboardPaths(result.stdout.toString('utf-8')); } -function isFileLikeNativeFormat(format: string): boolean { - const f = format.toLowerCase(); - const base = baseMimeType(format); - return ( - f.includes('file-url') || - f.includes('file url') || - f.includes('nsfilenames') || - f.includes('com.apple.finder') || - base === 'text/uri-list' || - base === 'public.url' - ); -} - -function safeAvailableFormats(clip: ClipboardModule | null): string[] { - if (clip?.availableFormats === undefined) return []; - try { - return clip.availableFormats(); - } catch { - return []; - } -} - async function readClipboardFileMediaViaNativeText( clip: ClipboardModule | null, ): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts index 051e9af46..cd136b403 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts @@ -18,6 +18,7 @@ export interface ClipboardModule { availableFormats?(): string[]; hasText?(): boolean; getText?(): Promise<string>; + setText?(text: string): Promise<void>; hasImage(): boolean; getImageBinary(): Promise<Array<number>>; } diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts new file mode 100644 index 000000000..8a8295f57 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process'; + +import { clipboard } from './clipboard-native'; + +function runClipboardCommand(command: string, args: readonly string[], input: string): void { + const result = spawnSync(command, args, { encoding: 'utf8', input }); + if (result.error) throw result.error; + if (result.status === 0) return; + + const detail = result.stderr.trim(); + throw new Error( + detail.length > 0 + ? `${command} exited with code ${String(result.status)}: ${detail}` + : `${command} exited with code ${String(result.status)}`, + ); +} + +async function copyWithPlatformCommand(text: string): Promise<void> { + const commands = + process.platform === 'darwin' + ? [{ command: 'pbcopy', args: [] as string[] }] + : process.platform === 'win32' + ? [{ command: 'clip.exe', args: [] as string[] }] + : [ + { command: 'wl-copy', args: [] as string[] }, + { command: 'xclip', args: ['-selection', 'clipboard'] }, + ]; + + let lastError: unknown; + for (const candidate of commands) { + try { + runClipboardCommand(candidate.command, candidate.args, text); + return; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error('No clipboard command is available.'); +} + +export async function copyTextToClipboard(text: string): Promise<void> { + const clipboardModule = clipboard; + if (clipboardModule?.setText !== undefined) { + try { + await clipboardModule.setText(text); + return; + } catch { + // Fall back to platform clipboard commands below. + } + } + + await copyWithPlatformCommand(text); +} diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts index 950542563..cade00bec 100644 --- a/apps/kimi-code/src/utils/history/input-history.ts +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -3,6 +3,10 @@ * * Semantics: * - One JSON object per line (`InputHistoryEntry { content }`) + * - `content` is the raw input. Shell commands are stored with a leading `!` + * (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore + * bash mode; the `!` is stripped again when the entry is recalled. Plain + * prompts (and legacy entries without a leading `!`) are normal prompts. * - Append-only writes * - Skip empty entries * - Skip when same as last entry (consecutive deduplication) diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index c5e246876..83b681f1c 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -10,7 +10,10 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { + KIMI_CODE_BANNER_DIR_NAME, + KIMI_CODE_BANNER_STATE_FILE_NAME, KIMI_CODE_BIN_DIR_NAME, + KIMI_CODE_CACHE_DIR_NAME, KIMI_CODE_DATA_DIR_NAME, KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, @@ -18,6 +21,7 @@ import { KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME, } from '#/constant/app'; @@ -41,6 +45,13 @@ export function getLogDir(): string { return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); } +/** + * Return the CLI cache directory: `<dataDir>/cache/`. + */ +export function getCacheDir(): string { + return join(getDataDir(), KIMI_CODE_CACHE_DIR_NAME); +} + /** * Return the managed tools directory: `<dataDir>/bin/`. */ @@ -69,6 +80,20 @@ export function getUpdateInstallLockFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); } +/** + * Return the rollout decision log: `<dataDir>/updates/rollout.log`. + */ +export function getUpdateRolloutLogFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); +} + +/** + * Return the banner display state file: `<dataDir>/cache/banner/state.json`. + */ +export function getBannerStateFile(): string { + return join(getCacheDir(), KIMI_CODE_BANNER_DIR_NAME, KIMI_CODE_BANNER_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `<share_dir>/user-history/<md5(cwd)>.jsonl`. diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index d11748a55..be55e798e 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -1,8 +1,10 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gt, valid } from 'semver'; + import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, @@ -29,6 +31,36 @@ export interface PluginMarketplace { readonly plugins: readonly PluginMarketplaceEntry[]; } +export type PluginUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +/** + * Compare a marketplace entry's (latest) version against the locally installed + * version. Only reports `update` when both are valid semver and latest > local, + * so a stale or non-semver version never produces a spurious or downgrading prompt. + */ +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): PluginUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + // Report only the actual installed version. When it is unknown, don't borrow the + // marketplace version — that would falsely claim "up to date" and hide future updates. + return { kind: 'up-to-date', version: local }; +} + interface MarketplaceLocation { readonly raw: string; readonly kind: 'remote' | 'local'; @@ -44,12 +76,37 @@ export interface LoadPluginMarketplaceOptions { export async function loadPluginMarketplace( options: LoadPluginMarketplaceOptions, ): Promise<PluginMarketplace> { + const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; const location = resolveMarketplaceLocation( - options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, + configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, options.workDir, ); - const raw = await readMarketplaceText(location, options.fetchImpl ?? fetch); - return parsePluginMarketplace(raw, location); + const fetchImpl = options.fetchImpl ?? fetch; + let raw: string; + try { + raw = await readMarketplaceText(location, fetchImpl); + } catch (error) { + const fallback = + configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; + if (fallback === undefined) throw error; + raw = await readMarketplaceText(fallback, fetchImpl); + return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); + } + 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 { @@ -92,6 +149,14 @@ function resolveMarketplaceLocation(source: string, workDir: string): Marketplac return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; } +async function getSourceCheckoutMarketplaceLocation(): Promise<MarketplaceLocation | undefined> { + const sourceDir = dirname(fileURLToPath(import.meta.url)); + const marketplacePath = resolve(sourceDir, '../../../../plugins/marketplace.json'); + const info = await stat(marketplacePath).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: marketplacePath, kind: 'local', resolved: marketplacePath }; +} + async function readMarketplaceText( location: MarketplaceLocation, fetchImpl: typeof fetch, @@ -115,24 +180,39 @@ function parseMarketplaceEntry( throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); } const id = requiredString(value, 'id', index); + validateMarketplaceEntryType(value, id); const source = stringField(value, 'source') ?? stringField(value, 'url') ?? stringField(value, 'downloadUrl'); 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'), }; } +function validateMarketplaceEntryType(value: Record<string, unknown>, id: string): void { + const raw = value['type']; + if (raw === undefined) return; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`); + } + const type = raw.trim(); + if (type === 'plugin' || type === 'managed' || type === 'guide') return; + throw new Error( + `Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`, + ); +} + function parseMarketplaceTier( value: Record<string, unknown>, id: string, @@ -170,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)); diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts index 1f8b588c2..7881238b6 100644 --- a/apps/kimi-code/src/utils/process/external-editor.ts +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -13,6 +13,8 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { quoteShellArg } from '#/utils/shell-quote'; + export function resolveEditorCommand(configured?: string | null): string | undefined { const candidates = [configured, process.env['VISUAL'], process.env['EDITOR']]; for (const c of candidates) { @@ -57,19 +59,3 @@ export async function editInExternalEditor( } } -/** - * Quote the appended temp-file path so spaces survive shell parsing. - */ -function quotePosixArg(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function quoteCmdArg(value: string): string { - return `"${value.replaceAll('"', '\\"')}"`; -} - -function quoteShellArg(value: string): string { - return process.platform === 'win32' - ? quoteCmdArg(value) - : quotePosixArg(value); -} diff --git a/apps/kimi-code/src/utils/shell-quote.ts b/apps/kimi-code/src/utils/shell-quote.ts new file mode 100644 index 000000000..eafe1c1e8 --- /dev/null +++ b/apps/kimi-code/src/utils/shell-quote.ts @@ -0,0 +1,11 @@ +export function quoteShellArg(value: string): string { + return process.platform === 'win32' ? quoteCmdArg(value) : quotePosixArg(value); +} + +function quotePosixArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function quoteCmdArg(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/apps/kimi-code/src/utils/terminal-restore.ts b/apps/kimi-code/src/utils/terminal-restore.ts new file mode 100644 index 000000000..5a93f3821 --- /dev/null +++ b/apps/kimi-code/src/utils/terminal-restore.ts @@ -0,0 +1,31 @@ +/** + * Best-effort terminal restoration for crash / emergency-exit paths. + * + * The normal shutdown path goes through pi-tui's `TUI.stop()`, which restores + * raw mode, the cursor, bracketed paste, and the Kitty / modifyOtherKeys + * keyboard protocols. When we bail out without running `TUI.stop()` — an + * uncaught exception, a SIGTERM whose cleanup throws, or a SIGHUP — the + * terminal would otherwise be left stuck in raw mode with a hidden cursor, and + * the user's shell would look broken afterwards. Writing these sequences lets + * the terminal recover. + * + * Every step is wrapped: the terminal may already be dead (EIO), and an exit + * path must never throw. + */ + +// Show cursor (`?25h`), disable bracketed paste (`?2004l`), pop the Kitty +// keyboard protocol (`<u`), and reset modifyOtherKeys (`>4;0m`). +const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\u001B[<u\u001B[>4;0m'; + +export function restoreTerminalModes(): void { + try { + process.stdin.setRawMode(false); + } catch { + // ignore — raw mode may not be active, or stdin may not be a TTY. + } + try { + process.stdout.write(TERMINAL_RESTORE_SEQUENCE); + } catch { + // ignore — the terminal may already be dead (EIO). + } +} diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts index 76d400506..87f72696c 100644 --- a/apps/kimi-code/src/utils/usage/debug-timing.ts +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -1,23 +1,107 @@ -export interface StepTimingInput { - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly usage?: { readonly output: number } | undefined; +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; + 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 +// measurable window. Below this threshold the duration is dominated by +// `Date.now()`'s ~1ms quantization (short / single-chunk tool-call turns can +// drain in 1ms), so dividing output tokens by it would report inflated rates +// like tens of thousands of tok/s. In that case we report the raw counts +// instead of a meaningless ratio. +const MIN_STREAM_MS_FOR_TPS = 50; + export function formatStepDebugTiming(input: StepTimingInput): string | undefined { const latency = input.llmFirstTokenLatencyMs; 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 && streamMs > 0) { - const tps = (outputTokens / (streamMs / 1000)).toFixed(1); - parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)})`); + 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)}${formatDecodeSplit(input)})`, + ); + } else { + parts.push( + `${outputTokens} tokens in ${formatDuration(streamMs)} (stream too short for TPS)`, + ); + } } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + return `[Debug] ${parts.join(' | ')}`; } +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +// Render TTFT, splitting the latency into the network + API-server portion and +// the in-process request-build portion when the provider reported the +// boundary. Falls back to the bare total otherwise. +function formatTtft(input: StepTimingInput): string { + const total = formatDuration(input.llmFirstTokenLatencyMs ?? 0); + const build = input.llmRequestBuildMs; + const server = input.llmServerFirstTokenMs; + if (build === undefined || server === undefined) return total; + return `${total} (api ${formatDuration(server)} + client ${formatDuration(build)})`; +} + +// Render the decode-window split as a trailing clause, e.g. +// `; server 4.6s + client 0.4s`. A large client share means the host's per-part +// processing is throttling decode. Empty when the provider did not report it. +function formatDecodeSplit(input: StepTimingInput): string { + const server = input.llmServerDecodeMs; + const client = input.llmClientConsumeMs; + if (server === undefined || client === undefined) return ''; + return `; server ${formatDuration(server)} + client ${formatDuration(client)}`; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index b6404c97d..afbda67c0 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -207,7 +207,7 @@ max_context_size = 0 `, 'utf-8', ); - await writeFile(join(dir, 'tui.toml'), 'theme = "blue"\n', 'utf-8'); + await writeFile(join(dir, 'tui.toml'), 'editor = 123\n', 'utf-8'); const { deps, stdout, stderr } = makeDeps(); const code = await handleDoctor(deps, {}); @@ -219,14 +219,14 @@ max_context_size = 0 expect(err).toContain(`ERROR config.toml ${join(dir, 'config.toml')}`); expect(err).toContain('max_context_size'); expect(err).toContain(`ERROR tui.toml ${join(dir, 'tui.toml')}`); - expect(err).toContain('theme'); + expect(err).toContain('editor'); }); it('formats Zod validation issues with field paths for tui.toml', async () => { await writeFile( join(dir, 'tui.toml'), ` -theme = "blue" +editor = 123 [notifications] enabled = "yes" @@ -240,7 +240,7 @@ enabled = "yes" expect(code).toBe(1); const err = stderr.join(''); expect(err).toContain('Validation issues:'); - expect(err).toContain('theme:'); + expect(err).toContain('editor:'); expect(err).toContain('notifications.enabled:'); }); diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 39963536e..14fdc0190 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -382,6 +382,7 @@ describe('kimi export', () => { exit: ((code: number) => { throw new ExitCalled(code); }) as ExportDeps['exit'], + getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }), }); await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], { @@ -411,6 +412,7 @@ describe('kimi export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index e8a9955d7..8d75c4721 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => { expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); }); describe('goal summary', () => { @@ -97,6 +103,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { session, @@ -116,6 +123,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: vi.fn() }, ensureConfigFile: vi.fn(), getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })), + getConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures), createSession: vi.fn(async () => mocks.session), resumeSession: vi.fn(async () => mocks.session), @@ -163,6 +171,8 @@ describe('runPrompt headless goal mode', () => { mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }]; mocks.sessions = []; mocks.session.createGoal.mockClear(); + mocks.session.prompt.mockClear(); + mocks.session.waitForBackgroundTasksOnPrint.mockClear(); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never); }); @@ -242,6 +252,113 @@ describe('runPrompt headless goal mode', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); }); + it('keeps listening across continuation turns until the goal is terminal', async () => { + const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 }); + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + await Promise.resolve(); + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' })); + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + expect(stderr.text()).toContain('turns: 2'); + }); + + it('ignores stale goal checks once a continuation turn has started', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + let resolveFirstGoal: ((value: { goal: null }) => void) | undefined; + const firstGoal = new Promise<{ goal: null }>((resolve) => { + resolveFirstGoal = resolve; + }); + mocks.session.getGoal + .mockImplementationOnce(() => firstGoal as never) + .mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of [...mocks.eventHandlers]) { + handler(mocks.mainEvent(event)); + } + }; + emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + emit({ type: 'assistant.delta', turnId: 1, delta: '1' }); + emit({ type: 'turn.ended', turnId: 1, reason: 'completed' }); + emit({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }); + emit({ type: 'assistant.delta', turnId: 2, delta: '2' }); + emit({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }); + resolveFirstGoal?.({ goal: null }); + await Promise.resolve(); + emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' }); + emit({ type: 'turn.ended', turnId: 2, reason: 'completed' }); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + }); + + it('does not send an invalid goal create prompt as a normal prompt', async () => { + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('Goal objective is too long'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); + }); + it('validates the resumed session model before creating a headless goal', async () => { mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }]; mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never); diff --git a/apps/kimi-code/test/cli/headless-exit.test.ts b/apps/kimi-code/test/cli/headless-exit.test.ts new file mode 100644 index 000000000..a977a70d2 --- /dev/null +++ b/apps/kimi-code/test/cli/headless-exit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Writable } from 'node:stream'; + +import { drainStdio, finalizeHeadlessRun, scheduleHeadlessForceExit } from '#/cli/headless-exit'; + +describe('scheduleHeadlessForceExit', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('force-exits with the lazily-resolved exit code after the grace period', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + let code = 0; + const handle = scheduleHeadlessForceExit({ exit }, () => code, 2000); + // The exit code can be set after scheduling (e.g. a goal turn maps its + // terminal status to process.exitCode); it must be read at fire time. + code = 7; + + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1999); + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(exit).toHaveBeenCalledWith(7); + + clearTimeout(handle); + }); + + it('schedules an unref\'d timer so a healthy run still exits naturally', () => { + // Real timers: an un-unref'd guard would itself keep the event loop alive, + // turning the fix into a regression (every healthy run would wait the full + // grace before exiting). hasRef() must be false. + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 60_000); + expect((handle as { hasRef?: () => boolean }).hasRef?.()).toBe(false); + clearTimeout(handle); + }); + + it('does not fire once cancelled via clearTimeout', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 2000); + clearTimeout(handle); + vi.advanceTimersByTime(5000); + expect(exit).not.toHaveBeenCalled(); + }); +}); + +describe('drainStdio', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves once buffered output has flushed', async () => { + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 5000).then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); // still draining + + flush?.(); // consumer caught up + await done; + expect(resolved).toBe(true); + }); + + it('gives up after the timeout when the consumer never drains', async () => { + vi.useFakeTimers(); + // write() never invokes its flush callback — a permanently-stuck consumer. + const stream = { write: vi.fn(() => false) } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 3000).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(resolved).toBe(true); + }); +}); + +describe('finalizeHeadlessRun', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes stdio before arming the force-exit so buffered output is not truncated', async () => { + vi.useFakeTimers(); + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + const exit = vi.fn(); + + const done = finalizeHeadlessRun({ exit }, [stream], () => 0, { + drainTimeoutMs: 5000, + graceMs: 2000, + }); + + // Output is still draining: even well past the force-exit grace, we must NOT + // have armed/fired the exit — doing so would truncate the buffered output. + await vi.advanceTimersByTimeAsync(4000); + expect(exit).not.toHaveBeenCalled(); + + // Consumer catches up → drain completes → only now is the backstop armed. + flush?.(); + await done; + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2000); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/kimi-code/test/cli/login.test.ts b/apps/kimi-code/test/cli/login.test.ts index 6a8a12b46..6644c7f21 100644 --- a/apps/kimi-code/test/cli/login.test.ts +++ b/apps/kimi-code/test/cli/login.test.ts @@ -122,6 +122,46 @@ describe('kimi login', () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it('still prints device code prompt when opening the browser fails', async () => { + vi.mocked(openUrl).mockImplementation(() => { + throw new Error('no browser'); + }); + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise<void>; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'kimi-code', ok: true }; + }, + ); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, + ); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + it('exits 1 when auth.login throws', async () => { mockLogin.mockRejectedValue(new Error('boom')); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 52aba94b1..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), + finalizeHeadlessRun: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), @@ -79,6 +81,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => { mocks.createKimiHarness(...args); return mocks.harness; }, + flushDiagnosticLogs: mocks.flushDiagnosticLogs, KimiHarness: MockKimiHarness, log: mocks.log, }; @@ -127,6 +130,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/headless-exit', () => ({ + finalizeHeadlessRun: mocks.finalizeHeadlessRun, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -147,6 +154,20 @@ function defaultOpts(): CLIOptions { }; } +async function waitForAssertion(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); @@ -196,6 +217,7 @@ describe('main entry command handling', () => { mocks.harness.close.mockResolvedValue(undefined); mocks.shutdownTelemetry.mockResolvedValue(undefined); mocks.handleUpgrade.mockResolvedValue(0); + mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); it('runs update preflight before starting the shell', async () => { @@ -235,6 +257,81 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('does not force-exit from the reusable handler in print mode', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + // Process disposition belongs to the entrypoint, never to this reusable, + // unit-tested handler: arming a process.exit here would kill the test runner + // or any embedding host. The handler only reports what ran. + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + expect(outcome).toEqual({ headlessCompleted: true }); + }); + + it('reports no headless completion for interactive (shell) mode', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + expect(outcome).toEqual({ headlessCompleted: false }); + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + }); + + it('arms the force-exit fallback at the entrypoint after a completed headless run', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + mocks.finalizeHeadlessRun.mockResolvedValue(void 0); + + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.finalizeHeadlessRun).toHaveBeenCalledTimes(1); + }); + // The exit code is resolved lazily so a goal turn that sets process.exitCode wins. + const forceExitArgs = mocks.finalizeHeadlessRun.mock.calls[0] as unknown as unknown[]; + expect(typeof forceExitArgs[2]).toBe('function'); + }); + + it('sets the failure exit code before awaiting startup failure logging', async () => { + const originalExitCode = process.exitCode; + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('provider failed')); + mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {})); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + + try { + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1); + }); + expect(process.exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + process.exitCode = originalExitCode; + } + }); + it('keeps shell mode update preflight interactive by default', async () => { const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 90fb53ecf..73d759841 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,13 +41,18 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); describe('--version', () => { it('prints the version string and exits', () => { let output = ''; - const program = createProgram('1.2.3', () => {}, () => {}); + const program = createProgram( + '1.2.3', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -61,7 +66,11 @@ describe('CLI options parsing', () => { it('supports -V as a short alias', () => { let output = ''; - const program = createProgram('4.5.6', () => {}, () => {}); + const program = createProgram( + '4.5.6', + () => {}, + () => {}, + ); program.exitOverride(); program.configureOutput({ writeOut: (s) => { @@ -103,9 +112,7 @@ describe('CLI options parsing', () => { '--flag', ]); - expect(pluginRunnerCalls).toEqual([ - { entry: '/plugin/tool.mjs', args: ['query', '--flag'] }, - ]); + expect(pluginRunnerCalls).toEqual([{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] }]); }); }); @@ -148,6 +155,10 @@ describe('CLI options parsing', () => { expect(parse(['-C']).continue).toBe(true); }); + it('-c is an alias for --continue', () => { + expect(parse(['-c']).continue).toBe(true); + }); + it('--continue and --session combined raises a conflict', () => { const opts = parse(['--continue', '--session', 'abc123']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -161,6 +172,50 @@ describe('CLI options parsing', () => { }); }); + describe('--auto / --yolo / --plan with --session / --continue', () => { + it('allows --auto with --continue', () => { + const opts = parse(['--auto', '--continue']); + expect(opts.auto).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --auto with an explicit session id', () => { + const opts = parse(['--auto', '--session', 'ses_123']); + expect(opts.auto).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with --continue', () => { + const opts = parse(['--yolo', '--continue']); + expect(opts.yolo).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --yolo with an explicit session id', () => { + const opts = parse(['--yolo', '--session', 'ses_123']); + expect(opts.yolo).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with --continue', () => { + const opts = parse(['--plan', '--continue']); + expect(opts.plan).toBe(true); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + + it('allows --plan with an explicit session id', () => { + const opts = parse(['--plan', '--session', 'ses_123']); + expect(opts.plan).toBe(true); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('shell'); + }); + }); + describe('--model / -m', () => { it('parses -m as a model override', () => { expect(parse(['-m', 'kimi-code/k2']).model).toBe('kimi-code/k2'); @@ -211,7 +266,9 @@ describe('CLI options parsing', () => { it('rejects prompt mode with bare --session picker', () => { const opts = parse(['-p', 'resume here', '--session']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow('Cannot use --session without an id in prompt mode.'); + expect(() => validateOptions(opts)).toThrow( + 'Cannot use --session without an id in prompt mode.', + ); }); it('rejects prompt mode with --yolo because prompt mode always uses auto permission', () => { @@ -255,6 +312,16 @@ describe('CLI options parsing', () => { }); }); + describe('--add-dir', () => { + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); + }); + }); + describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { let upgradeCalls = 0; @@ -280,8 +347,36 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes update alias to the upgrade handler', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'update']); + + expect(upgradeCalls).toBe(1); + }); + it('registers the visible sub-commands', () => { - const program = createProgram('0.0.0', () => {}, () => {}); + const program = createProgram( + '0.0.0', + () => {}, + () => {}, + ); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); @@ -289,8 +384,11 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', + 'server', + 'web', 'login', 'doctor', + 'vis', 'migrate', 'upgrade', ]); @@ -308,7 +406,6 @@ describe('CLI options parsing', () => { '--print', '--wire', '--agent=default', - '--add-dir=/', '--raw-model', '--config-file=x', '--quiet', diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index d1f6a05e6..2e4aeece4 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -546,6 +546,30 @@ describe('registerProviderCommand', () => { expect(Object.keys(current().providers).toSorted()).toEqual(['kohub', 'kohub-responses']); expect(stdout.join('')).toContain('Imported 2 providers'); }); + + it('reports write failures on stderr and exits 1 instead of crashing', async () => { + const { harness } = makeHarness({ + providers: { kimi: { type: 'kimi' } }, + } as unknown as KimiConfig); + // Simulate the strict write path rejecting because config.toml is invalid. + harness.removeProvider = async () => { + throw new Error( + 'Cannot change settings while config.toml is invalid — fix it first (run `kimi doctor` for details).', + ); + }; + const { deps, stderr, exitCodes } = makeDeps(harness); + + const program = new Command('kimi'); + registerProviderCommand(program, deps); + + await tryRun(() => + program.parseAsync(['node', 'kimi', 'provider', 'remove', 'kimi'], { from: 'node' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Cannot change settings'); + expect(stderr.join('')).not.toContain(' at '); // no stack trace dump + }); }); describe('kimi provider catalog list', () => { @@ -644,7 +668,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'other/main', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, stdout, exitCodes } = makeDeps(harness); @@ -668,7 +692,7 @@ describe('kimi provider catalog add', () => { // The unrelated provider's model survives, and remains the default. expect(finalConfig.models?.['other/main']).toBeDefined(); expect(finalConfig.defaultModel).toBe('other/main'); - expect(finalConfig.defaultThinking).toBe(true); + expect(finalConfig.thinking?.enabled).toBe(true); // The patch sent over `setConfig` must explicitly carry the preserved default. expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); @@ -736,7 +760,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'anthropic/claude-opus-4-7', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -749,19 +773,19 @@ describe('kimi provider catalog add', () => { expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); // Previous default and thinking flag must survive the re-import. expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); }); - it('preserves default_thinking when --default-model is supplied to a thinking-capable model', async () => { + it('preserves thinking.enabled when --default-model is supplied to a thinking-capable model', async () => { // Regression test for the codex P2: `applyCatalogProvider` always - // assigns `defaultThinking` from `options.thinking`. Hardcoding `false` + // assigns `thinking.enabled` from `options.thinking`. Hardcoding `false` // silently disabled thinking even when the user previously had it on // and is just importing a known provider. The handler now threads the // previous value through. mockRegistryFetch(CATALOG_BODY); const initial: KimiConfig = { providers: {}, - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -775,20 +799,20 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([]); expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); - expect(setConfigCalls[0]?.defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); + expect(setConfigCalls[0]?.thinking?.enabled).toBe(true); }); - it('does not persist default_thinking=false for first-time setup with --default-model', async () => { + it('does not persist thinking.enabled=false for first-time setup with --default-model', async () => { // Regression test for codex P2 follow-up: previously the handler fell - // back to `false` when `defaultThinking` was unset, but - // `resolveThinkingLevel` treats `defaultThinking === false` as an + // back to `false` when `thinking.enabled` was unset, but + // `resolveThinkingEffort` treats `thinking.enabled === false` as an // explicit "off" request. A fresh `kimi provider catalog add // anthropic --default-model claude-opus-4-7` must NOT silently disable - // thinking — it should leave `defaultThinking` unset so the runtime + // thinking — it should leave `thinking.enabled` unset so the runtime // uses the per-model default. mockRegistryFetch(CATALOG_BODY); - // Note: `defaultThinking` is omitted on purpose to model a fresh user. + // Note: `thinking.enabled` is omitted on purpose to model a fresh user. const { harness, current, setConfigCalls } = makeHarness({ providers: {}, } as KimiConfig); @@ -805,8 +829,8 @@ describe('kimi provider catalog add', () => { expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); // Must NOT be `false`. `undefined` lets the runtime resolver pick the // per-model default; `false` would force `'off'`. - expect(current().defaultThinking).toBeUndefined(); - expect(setConfigCalls[0]?.defaultThinking).toBeUndefined(); + expect(current().thinking?.enabled).toBeUndefined(); + expect(setConfigCalls[0]?.thinking?.enabled).toBeUndefined(); }); it('drops a stale default_model when the catalog refresh no longer contains it', async () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index e4a2d2149..cee7c2146 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -2,6 +2,7 @@ import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/ki import { afterEach, describe, expect, it, vi } from 'vitest'; import { runPrompt } from '#/cli/run-prompt'; +import { PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; type CreateKimiDeviceId = typeof createKimiDeviceIdFn; @@ -38,6 +39,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { @@ -54,6 +56,7 @@ const mocks = vi.hoisted(() => { telemetry: true, }), ), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetExperimentalFeatures: vi.fn(async () => []), harnessCreateSession: vi.fn(async () => session), harnessResumeSession: vi.fn(async () => session), @@ -91,6 +94,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken }, ensureConfigFile: mocks.harnessEnsureConfigFile, getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, getExperimentalFeatures: mocks.harnessGetExperimentalFeatures, createSession: mocks.harnessCreateSession, resumeSession: mocks.harnessResumeSession, @@ -133,6 +137,7 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { outputFormat: undefined, prompt: 'say hello', skillsDirs: [], + addDirs: [], ...overrides, }; } @@ -203,6 +208,8 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'k2', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.session.setPermission).not.toHaveBeenCalled(); expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -210,10 +217,100 @@ describe('runPrompt', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ses_prompt' }), + ); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Simulate a shutdown step that hangs (e.g. a wedged SessionEnd hook or a + // blackholed connection in a firewalled sandbox). A completed headless run + // must not stay alive forever waiting on cleanup. + mocks.harnessClose.mockReturnValueOnce(new Promise<void>(() => {})); + + let settled = false; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + + expect(settled).toBe(true); + expect(mocks.harnessClose).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('propagates a cleanup failure that settles before the timeout', async () => { + const stdout = writer(); + const stderr = writer(); + // A cleanup step that fails fast (e.g. a permission restore or harness close + // hitting a persistence error) must surface — not be silently swallowed by + // the timeout guard — otherwise the run reports success while shutdown + // actually failed (e.g. a resumed session left in `auto`). + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); + + await expect( + runPrompt(opts(), '1.2.3-test', { stdout, stderr, process: fakeProcess() }), + ).rejects.toThrow('close failed'); + }); + + it('ignores a cleanup rejection that lands after the timeout', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Cleanup overruns the bound and only rejects later. The run already gave + // up waiting and resolved; that late rejection must not flip it to a + // failure (nor surface as an unhandled rejection). + mocks.harnessClose.mockReturnValueOnce( + new Promise<void>((_, reject) => { + const timer = setTimeout( + () => reject(new Error('late close')), + PROMPT_CLEANUP_TIMEOUT_MS + 5000, + ); + timer.unref?.(); + }), + ); + + let settled: 'resolved' | 'rejected' | undefined; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + expect(settled).toBe('resolved'); + + await vi.advanceTimersByTimeAsync(5000); + await Promise.resolve(); + expect(settled).toBe('resolved'); + } finally { + vi.useRealTimers(); + } + }); + it('stops prompt startup when session creation fails', async () => { const stdout = writer(); const stderr = writer(); @@ -240,12 +337,29 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'kimi-code/k2.5', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ model: 'kimi-code/k2.5' }), ); }); + it('passes the CLI additional directory when creating a fresh prompt session', async () => { + await runPrompt(opts({ addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'k2', + permission: 'auto', + additionalDirs: ['../shared', '/tmp/extra'], + drainAgentTasksOnStop: true, + }); + }); + it('tracks first launch in prompt mode before harness construction can create the device id', async () => { mocks.harnessCreatesDeviceIdOnConstruction = true; const createdHomes = new Set<string>(); @@ -440,6 +554,23 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when resuming a concrete session', async () => { + await runPrompt( + opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }), + '1.2.3-test', + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }, + ); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_existing', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -535,6 +666,90 @@ describe('runPrompt', () => { ); }); + it('emits a stream-json meta line on retry and discards the failed attempt output', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' })); + handler( + mocks.mainEvent({ + type: 'turn.step.retrying', + turnId: 10, + step: 1, + stepId: 'step-uuid', + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 300, + errorName: 'APIProviderRateLimitError', + errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429', + statusCode: 429, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + const retryMeta = JSON.stringify({ + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: 1, + next_attempt: 2, + max_attempts: 3, + delay_ms: 300, + error_name: 'APIProviderRateLimitError', + error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429', + status_code: 429, + }); + expect(stdout.text()).toBe( + [ + retryMeta, + '{"role":"assistant","content":"final answer"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '', + ].join('\n'), + ); + // The failed attempt's partial text must not leak as an assistant line. + expect(stdout.text()).not.toContain('partial attempt'); + expect(stderr.text()).toBe(''); + }); + + it('flushes stream-json assistant output before waiting for background tasks', async () => { + let releaseWait: () => void = () => {}; + const waitGate = new Promise<void>((resolve) => { + releaseWait = resolve; + }); + mocks.session.waitForBackgroundTasksOnPrint.mockImplementationOnce(async () => waitGate); + + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 9, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 9, reason: 'completed' })); + } + }); + + const stdout = writer(); + const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + // The assistant message must be flushed even while the background wait is pending. + await waitForAssertion(() => { + expect(stdout.text()).toContain('{"role":"assistant","content":"final answer"}'); + }); + + releaseWait(); + await runPromise; + }); + it('resumes a concrete session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); @@ -565,6 +780,19 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when continuing the previous session', async () => { + await runPrompt(opts({ continue: true, addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_previous', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); @@ -656,6 +884,47 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); + it.each([ + ['SIGTERM' as NodeJS.Signals, 143], + ['SIGHUP' as NodeJS.Signals, 129], + ])('cleans up prompt mode before exiting on %s', async (signal, exitCode) => { + let releasePrompt!: () => void; + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 7, origin: { kind: 'user' } }), + ); + } + await new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + }); + const processMock = fakeProcess(); + const run = runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + process: processMock, + } as Parameters<typeof runPrompt>[2] & { process: ReturnType<typeof fakeProcess> }); + + await waitForAssertion(() => { + expect(processMock.listener(signal)).toBeDefined(); + }); + + await processMock.listener(signal)?.(); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + expect(processMock.exit).toHaveBeenCalledWith(exitCode); + + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 7, reason: 'completed' })); + } + releasePrompt(); + await run; + + expect(mocks.harnessClose).toHaveBeenCalledTimes(1); + }); + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; let releasePrompt!: () => void; @@ -764,6 +1033,31 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('rejects with a friendly message when the provider filters the response', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 2, + reason: 'filtered', + }), + ); + } + }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('Provider safety policy blocked the response.'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { await runPrompt(opts(), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c55a5e590..eafc4a9e0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => { defaultModel: 'k2', telemetry: true, })), + harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetCachedAccessToken: vi.fn(), harnessClose: vi.fn(), detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null), @@ -82,6 +83,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { }, ensureConfigFile: mocks.harnessEnsureConfigFile, getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, close: mocks.harnessClose, track: mocks.harnessTrack, }; @@ -179,6 +181,7 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -189,13 +192,14 @@ describe('runShell', () => { userAgentProduct: 'kimi-code-cli', version: '1.2.3-test', }), + sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false }, }), ); expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessGetConfig.mock.invocationCallOrder[0]!, ); - expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' }); + expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1); expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( '/tmp/kimi-code-test-home', @@ -209,6 +213,7 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', + sessionId: undefined, getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -217,6 +222,7 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], tuiConfig: { theme: 'dark', editorCommand: null, @@ -224,18 +230,9 @@ describe('runShell', () => { }, version: '1.2.3-test', workDir: process.cwd(), - resolvedTheme: 'dark', }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); - expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: false, - yolo: true, - auto: false, - plan: true, - afk: false, - }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), @@ -244,6 +241,34 @@ describe('runShell', () => { }); }); + it('forwards skillsDirs from CLI options to the harness', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: ['/skills'], + }, + '1.2.3-test', + ); + + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ skillDirs: ['/skills'] }), + ); + }); + it('tracks first launch when device id creation reports first launch', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -326,39 +351,6 @@ describe('runShell', () => { expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch'); }); - it('marks resumed lifecycle starts from session flags', async () => { - mocks.loadTuiConfig.mockResolvedValue({ - theme: 'dark', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - }); - mocks.tuiStart.mockResolvedValue(undefined); - mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); - - await runShell( - { - session: 'ses-1', - continue: false, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - ); - - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: true, - yolo: false, - auto: false, - plan: false, - afk: false, - }); - }); - it('binds startup_perf to the session captured before MCP metrics resolve', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -388,9 +380,9 @@ describe('runShell', () => { '1.2.3-test', ); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(1, { sessionId: 'ses-startup' }); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(2, { sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenNthCalledWith(2, 'startup_perf', { + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); + expect(mocks.withTelemetryContext).not.toHaveBeenCalledWith({ sessionId: 'ses-later' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), init_ms: expect.any(Number), @@ -435,13 +427,13 @@ describe('runShell', () => { harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { outcome: 'success' }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'unauthorized', }); expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, + outcome: 'error', reason: 'network_or_other', }); }); @@ -476,7 +468,6 @@ describe('runShell', () => { const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', - resolvedTheme: 'light', tuiConfig: { theme: 'auto', editorCommand: 'vim', @@ -485,6 +476,38 @@ describe('runShell', () => { }); }); + it('forwards config.toml diagnostics as startup notices', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.harnessGetConfigDiagnostics.mockResolvedValue({ + warnings: ['Ignored invalid config in config.toml: loop_control.'], + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ + startupNotice: 'Ignored invalid config in config.toml: loop_control.', + }); + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -499,7 +522,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -511,7 +534,7 @@ describe('runShell', () => { ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number) }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); @@ -536,7 +559,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, @@ -557,7 +580,7 @@ describe('runShell', () => { expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { - duration_s: expect.any(Number), + duration_ms: expect.any(Number), }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); @@ -570,6 +593,53 @@ describe('runShell', () => { } }); + it('prints the opened web URL from the TUI exit handler when set', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); + mocks.tuiHasSessionContent.mockReturnValue(true); + + const stdout = captureProcessWrite('stdout'); + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1'; + (tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl; + + await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf( + ExitCalled, + ); + + expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1'); + expect(stderr.text()).toContain('open '); + expect(stderr.text()).toContain(openedUrl); + } finally { + exitSpy.mockRestore(); + stdout.restore(); + stderr.restore(); + } + }); + it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -589,7 +659,7 @@ describe('runShell', () => { session: undefined, continue: false, yolo: false, - auto: false, + auto: false, plan: false, model: undefined, outputFormat: undefined, diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts new file mode 100644 index 000000000..b6ee71f95 --- /dev/null +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -0,0 +1,1770 @@ +/** + * Tests for `kimi server run` and `kimi web` Commander wiring. + * + * These tests don't actually start the server — they verify the parsed shape + * (option flags, --open default) and that the `web` alias defers to the same + * underlying handler with `defaultOpen` flipped to true. + * + * Foreground startup behavior is exercised end-to-end in `server-e2e/`. + */ + +import type { ChildProcess } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerServerCommand } from '#/cli/sub/server'; +import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; +import type { KillCommandDeps } from '#/cli/sub/server/kill'; +import { darkColors } from '#/tui/theme/colors'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: vi.fn() }; +}); + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + return program; +} + +describe('kimi server', () => { + it('registers the expected `server` subcommands while lifecycle commands are hidden', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + const subs = server?.commands.map((c) => c.name()).toSorted(); + expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']); + }); + + it('`server run` exposes local-only foreground options', () => { + const program = makeProgram(); + const run = program.commands + .find((c) => c.name() === 'server') + ?.commands.find((c) => c.name() === 'run'); + expect(run).toBeDefined(); + const longs = run!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); + expect(longs).toContain('--foreground'); + // run defaults to NOT opening the browser → option is the positive --open + expect(longs).toContain('--open'); + }); + + it('`server install` exposes local-only service options', () => { + // Lifecycle commands are no longer registered via `registerServerCommand`, + // but the builder still lives in `./lifecycle` — exercise it directly. + const server = new Command('server'); + addLifecycleCommands(server); + const install = server.commands.find((c) => c.name() === 'install'); + expect(install).toBeDefined(); + const longs = install!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--force'); + expect(longs).toContain('--no-open'); + expect(longs).toContain('--json'); + }); + + it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + // web defaults to opening → the option is the negative form --no-open + expect(longs).toContain('--no-open'); + expect(longs).toContain('--host'); + expect(longs).toContain('--port'); + }); +}); + +describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { + it('the dispatcher returns a friendly error manager for unknown platforms', async () => { + // darwin / linux / win32 have real backends (launchd / systemd / schtasks). + // The remaining platforms fall through to the stub that throws + // `ServiceUnsupportedError` — pin that contract so a future addition + // (freebsd, etc.) needs a deliberate decision instead of silently working. + const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/server'); + const mgr = resolveServiceManager('freebsd'); + await expect( + mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), + ).rejects.toBeInstanceOf(ServiceUnsupportedError); + await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); + }); +}); + +describe('`kimi server` lifecycle handles unavailable service managers', () => { + it('prints a friendly JSON error and exits 2', async () => { + const { ServiceUnavailableError } = await import('@moonshot-ai/server'); + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => { + throw new ServiceUnavailableError( + 'linux', + 'systemd --user is not available in this environment.', + ); + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ platform: 'linux', installed: false, running: false }), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await expect( + program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), + ).rejects.toThrow('process.exit(2)'); + + exit.mockRestore(); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + action: 'unavailable', + platform: 'linux', + message: expect.stringContaining('server run --port <port>'), + }); + }); +}); + +describe('`kimi server` lifecycle output', () => { + it('install passes --force/--port, prints the URL, and opens it when running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + let installArgs: unknown; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async (args) => { + installArgs = args; + return { + status: 'replaced', + message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', + plistPath: '/tmp/kimi.plist', + }; + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: true, + host: '127.0.0.1', + port: 9999, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await program.parseAsync([ + 'node', + 'kimi', + 'server', + 'install', + '--force', + '--port', + '9999', + ]); + + expect(stderr).toBe(''); + expect(installArgs).toMatchObject({ port: 9999, force: true }); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Status: running'); + expect(stdout).toContain('Log: /tmp/server.log'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('start prints URL and diagnostics when launchd did not keep the service running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => ({ status: 'installed', message: 'unused' }), + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: false, + host: '127.0.0.1', + port: 58627, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }); + + await program.parseAsync(['node', 'kimi', 'server', 'start']); + + expect(stdout).toContain('URL: http://127.0.0.1:58627'); + expect(stdout).toContain('Status: not running'); + expect(stdout).toContain('launchd state: spawn scheduled'); + expect(stdout).toContain('last exit code: 78 EX_CONFIG'); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`kimi server run` background start', () => { + it('defaults the daemon log level to silent', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'silent' }); + }); + + it('passes --log-level through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', logLevel: 'debug' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'debug' }); + }); + + it('warns and uses the running server host when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + // The user asks for a public bind, but a loopback daemon is already up. + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // A clear notice that a server was already running and options were ignored. + expect(plain).toContain('A server is already running'); + expect(plain).toContain('kimi server kill'); + // The banner uses the *actual* host (loopback), not the requested 0.0.0.0 — + // so it shows a Local URL plus the "network disabled" hint, NOT real + // Network addresses (which would be misleading since nothing binds them). + expect(plain).toContain('http://127.0.0.1:58627/#token=tok'); + expect(plain).toContain('use --host to enable'); + expect(plain).not.toContain('Network: http'); + }); + + it('keeps the token and skips the bypass notice when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + // The user requests bypass, but a daemon is already running — so the + // requested flag is NOT applied to the server actually serving requests. + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // No false "bypass" claim for a server whose real auth mode is unknown. + expect(plain).not.toContain('DANGER'); + // The token is preserved so the browser can auto-authenticate to the + // reused (token-protected) daemon. + expect(plain).toContain('#token=tok'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok'); + }); + + it('prints a TUI-style ready panel once the daemon is up', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '58627', host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('Local:'); + expect(plain).toContain('http://127.0.0.1:58627/'); + // Loopback bind shows a Network hint for enabling network access. + expect(plain).toContain('Network:'); + expect(plain).toContain('use --host to enable'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('kimi server kill'); + // Version sits on the title line; no separate Ready:/Version: rows and no + // startup-time metric. + expect(plain).not.toContain('Ready:'); + expect(plain).not.toContain('Version:'); + expect(plain).not.toContain(' ms'); + // No bordered panel (the token URL must print in full for copying), but + // the Kimi sprite stays next to the title. + expect(plain).not.toContain('╭'); + expect(plain).not.toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); + expect(plain).not.toContain('➜'); + expect(plain).not.toContain('Kimi server:'); + + // Title is above the URLs; Logs/Stop are at the bottom. + expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:')); + expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:')); + }); + + it('uses the TUI dark palette for the ready banner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '58627', host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); + expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); + }); + + it('prints a red danger notice and suppresses the token when auth is bypassed', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok', + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + // Red, impossible-to-miss danger notice. + expect(plain).toContain('DANGER: authentication is DISABLED'); + expect(plain).toContain('--dangerous-bypass-auth'); + expect(plain).toContain('kimi server kill'); + // The token is irrelevant when bypassed — neither printed nor carried in + // any URL (so it cannot leak via copy/paste of the banner). + expect(plain).not.toContain('tok'); + expect(plain).not.toContain('#token='); + // The opened browser URL carries no token fragment either. + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('renders the bypass danger notice in the error color', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain( + color.bold.hex(darkColors.error)('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).'), + ); + }); +}); + +describe('`kimi server run --foreground`', () => { + it('runs the server in-process instead of spawning a background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + let backgroundCalled = false; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: async () => { + backgroundCalled = true; + return { origin: 'http://127.0.0.1:58627' }; + }, + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(backgroundCalled).toBe(false); + expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); + }); + + it('prints the ready banner and opens the browser once listening', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', host: '127.0.0.1', foreground: true, open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options, hooks) => { + void options; + hooks?.onReady?.('http://127.0.0.1:58627'); + return undefined as unknown as never; + }, + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('http://127.0.0.1:58627/'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server` does not register a legacy `daemon` command', () => { + it('hard-deletes the old name', () => { + const program = makeProgram(); + const daemon = program.commands.find((c) => c.name() === 'daemon'); + expect(daemon).toBeUndefined(); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/server/shared'); + expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 58627)).toBe(58627); + expect(parsePort('8080', '--port', 58627)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/server/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + +function listenOnce(host: string, port: number): Promise<Server> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host, port }, () => { + resolve(server); + }); + }); +} + +function closeServer(server: Server): Promise<void> { + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); +} + +async function allocateFreePort(host = '127.0.0.1'): Promise<number> { + const server = await listenOnce(host, 0); + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + await closeServer(server); + return port; +} + +/** + * Find the start of a run of `count` consecutive free ports + * (`start`, `start + 1`, …, `start + count - 1` all bindable). + */ +async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promise<number> { + for (let i = 0; i < 50; i++) { + const start = await allocateFreePort(host); + if (start <= 0 || start + count - 1 > 65535) continue; + const held: Server[] = []; + let ok = true; + for (let offset = 1; offset < count; offset++) { + const probe = await listenOnce(host, start + offset).catch(() => null); + if (probe === null) { + ok = false; + break; + } + held.push(probe); + } + for (const server of held) await closeServer(server); + if (ok) return start; + } + throw new Error('could not allocate a run of adjacent free ports'); +} + +describe('--host threading (M6.2)', () => { + it('passes --host through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 }); + }); + + it('passes --host through to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' }); + }); +}); + +describe('default bind (M6.3)', () => { + it('defaults host to 127.0.0.1 and insecureNoTls to true when no flags are passed', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '127.0.0.1', insecureNoTls: true }); + }); + + it('treats a bare --host as the default LAN host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); +}); + +describe('--allowed-host threading', () => { + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/server/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); + + it('threads --allowed-host to the background daemon options', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', allowedHost: ['.example.com'] }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ allowedHosts: ['.example.com'] }); + }); +}); + +describe('--keep-alive (no 60s idle-kill)', () => { + it('defaults to off for the plain loopback daemon', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({}).keepAlive).toBe(false); + }); + + it('is implied by a bare --host (default LAN host)', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: true }).keepAlive).toBe(true); + }); + + it('is implied by an explicit --host value', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: '0.0.0.0' }).keepAlive).toBe(true); + expect(parseServerOptions({ host: '192.168.1.5' }).keepAlive).toBe(true); + }); + + it('stays off for an explicit loopback --host with no allowed-hosts', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ host: '127.0.0.1' }).keepAlive).toBe(false); + }); + + it('is implied by --allowed-host (proxy/tunnel)', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ allowedHost: ['.example.com'] }).keepAlive).toBe(true); + }); + + it('can be set explicitly on a loopback daemon', async () => { + const { parseServerOptions } = await import('#/cli/sub/server/shared'); + expect(parseServerOptions({ keepAlive: true }).keepAlive).toBe(true); + }); + + it('is forced on in --foreground mode even on the default loopback host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ keepAlive: true }); + }); + + it('threads keepAlive to the foreground runner when implied by --host', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ keepAlive: true }); + }); +}); + +describe('lockConnectHost (M6.2 connect side)', () => { + it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + // The daemon binds 0.0.0.0 (all interfaces), but the local CLI must + // connect over loopback — 0.0.0.0 is not a connectable address. The token + // then rides on that loopback connection (covered by the M5.4 kill/ps + // Authorization tests). + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe( + '127.0.0.1', + ); + }); + + it('preserves a loopback / concrete bind host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe( + '127.0.0.1', + ); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe( + '192.168.1.5', + ); + }); + + it('falls back to 127.0.0.1 when the lock has no host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1'); + }); +}); + +describe('--insecure-no-tls threading (M6.3)', () => { + it('threads --insecure-no-tls to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true, foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('threads --insecure-no-tls to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ insecureNoTls: true }); + }); +}); + +describe('ready banner reflects the bind class (M6.3)', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('Network:'); + // Full token-bearing URLs are printed plainly (no box, no truncation) so + // they are easy to copy. + expect(raw).toContain('http://localhost:58627/#token=tok-xyz'); + expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz'); + expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-xyz'); + expect(raw).not.toContain('╭'); + }); + + it('prints the Local URL and token for a 127.0.0.1 bind', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-loop', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + // Full token-bearing URL, printed plainly for copying. + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + expect(raw).not.toContain('╭'); + }); +}); + +describe('resolveDaemonPort', () => { + it('returns the preferred port when it is free', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const free = await allocateFreePort(); + await expect(resolveDaemonPort('127.0.0.1', free)).resolves.toBe(free); + }); + + it('falls back to a different free port when the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const busy = await allocateFreePort(); + const holder = await listenOnce('127.0.0.1', busy); + try { + const port = await resolveDaemonPort('127.0.0.1', busy); + expect(port).not.toBe(busy); + expect(port).toBeGreaterThan(0); + } finally { + await closeServer(holder); + } + }); + + it('walks to preferred+1 when only the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(2); + const holder = await listenOnce('127.0.0.1', start); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 1); + } finally { + await closeServer(holder); + } + }); + + it('skips past a run of busy ports to the first free one', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(3); + // Hold both `start` and `start+1`; the resolver should land on `start+2`. + const holderA = await listenOnce('127.0.0.1', start); + const holderB = await listenOnce('127.0.0.1', start + 1); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 2); + } finally { + await closeServer(holderA); + await closeServer(holderB); + } + }); +}); + +describe('resolveDaemonProgram', () => { + it('uses the absolute script path outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); + }); + + it('normalizes a relative executable path against cwd outside SEA mode', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe(resolve('/tmp/kimi-bin', './kimi')); + }); + + it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { + // Reproduces `kimi web` from the shell: argv[1] is the invoked command + // name (`kimi`), not a path. Resolving it against cwd produced `<cwd>/kimi` + // and crashed the spawn with ENOENT. + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); + + it('returns execPath in SEA mode for a spawned `server` child', async () => { + const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); + expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); + }); +}); + +describe('spawnDaemonChild', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-cwd-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('spawns the daemon with cwd set to the server log directory', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info' }); + + expect(spawnMock).toHaveBeenCalledOnce(); + const [program, args, options] = spawnMock.mock.calls[0]!; + expect(program).toBeTruthy(); + expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); + expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); + expect(options?.cwd).not.toBe(process.cwd()); + }); + + it('passes --host through to the daemon child args (M6.2)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0'])); + }); + + it('passes --insecure-no-tls through to the daemon child args (M6.3)', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls'])); + }); + + it('passes --allowed-host through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', allowedHosts: ['.example.com'] }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--allowed-host', '.example.com'])); + }); + + it('passes --keep-alive through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', keepAlive: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--keep-alive'])); + }); +}); + +describe('ensureDaemon surfaces boot failures via early exit', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon'); + + // Seed the daemon log with the kind of line a failing boot writes, so we + // can assert it is surfaced to the user instead of a generic timeout. + mkdirSync(dirname(daemonLogPath()), { recursive: true }); + writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n'); + + // Fake child that exits with code 1 shortly after the 'exit' listener is + // attached — simulating a daemon that fails during boot. + const fakeChild = { + unref: vi.fn(), + once: vi.fn((event: string, cb: (...a: unknown[]) => void) => { + if (event === 'exit') { + setTimeout(() => { + cb(1, null); + }, 5); + } + return fakeChild; + }), + }; + spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess); + + const start = Date.now(); + let caught: unknown; + try { + await ensureDaemon({ port: 0 }); + } catch (error) { + caught = error; + } + const elapsed = Date.now() - start; + + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).toMatch(/exited with code 1/); + expect(message).toContain('Refusing to bind'); + // Must fail fast — nowhere near the 20s spawn timeout. + expect(elapsed).toBeLessThan(5000); + }); +}); + +describe('createIdleShutdownHandler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not arm before any client connects', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('fires onIdle after the grace once the last client leaves', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(999); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending exit when a client reconnects during the grace', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(500); + handler.onConnectionCountChange(1); // reconnect + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('only the final drop to zero arms the timer with multiple clients', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 500, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(2); + handler.onConnectionCountChange(1); // still one connected + vi.advanceTimersByTime(1000); + expect(onIdle).not.toHaveBeenCalled(); + handler.onConnectionCountChange(0); // now none + vi.advanceTimersByTime(500); + expect(onIdle).toHaveBeenCalledTimes(1); + }); +}); + +describe('kimi web (shares `server run` call stack)', () => { + it('prints the ready banner and opens the browser by default', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(stripAnsi(stdout)).toContain('Kimi server ready'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('does not open the browser when open is false', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:9000' }), + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('rejects an invalid --log-level before touching the daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const startServerBackground = vi.fn(); + await expect( + handleRunCommand( + { logLevel: 'shout' }, + { + startServerBackground, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ), + ).rejects.toThrow(/invalid --log-level/); + expect(startServerBackground).not.toHaveBeenCalled(); + }); +}); + +function makeKillDeps(overrides: Partial<KillCommandDeps> = {}): { + deps: KillCommandDeps; + writes: string[]; + signals: Array<{ pid: number; signal: NodeJS.Signals }>; + state: { shutdownCalls: number }; + clock: { t: number }; +} { + const writes: string[] = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const state = { shutdownCalls: 0 }; + const clock = { t: 0 }; + const deps: KillCommandDeps = { + getLiveLock: () => undefined, + requestShutdown: async () => { + state.shutdownCalls += 1; + }, + resolveToken: () => undefined, + signalPid: (pid, signal) => { + signals.push({ pid, signal }); + return true; + }, + pidAlive: () => false, + sleep: async (ms) => { + clock.t += ms; + }, + stdout: { + write(chunk: string | Uint8Array) { + writes.push(String(chunk)); + return true; + }, + }, + now: () => clock.t, + ...overrides, + }; + return { deps, writes, signals, state, clock }; +} + +describe('`kimi server kill`', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('prints "No running Kimi server." and sends no signal when no live lock exists', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals } = makeKillDeps({ getLiveLock: () => undefined }); + + await handleKillCommand(deps); + + expect(writes.join('')).toContain('No running Kimi server.'); + expect(signals).toEqual([]); + }); + + it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, state, clock } = makeKillDeps({ + getLiveLock: () => liveLock, + pidAlive: () => clock.t < 50, + }); + + await handleKillCommand(deps); + + expect(state.shutdownCalls).toBe(1); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 1234'); + expect(writes.join('')).toContain('stopped.'); + }); + + it('escalates to SIGKILL when the pid survives SIGTERM', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, clock } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 5678 }), + // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. + pidAlive: () => clock.t < 3100, + }); + + await handleKillCommand(deps); + + expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); + expect(writes.join('')).toContain('pid 5678'); + expect(writes.join('')).toContain('killed.'); + }); + + it('throws a permissions error when the pid survives SIGKILL', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 9999 }), + pidAlive: () => true, + }); + + await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + }); +}); + +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from <homeDir>/server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), 'secret-token\n'); + expect(resolveServerToken(dir)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), ' tok \n'); + expect(resolveServerToken(dir)).toBe('tok'); + }); + + it('throws a clear error when the token file is missing', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); + }); +}); + +describe('authHeaders', () => { + it('builds a Bearer Authorization header', async () => { + const { authHeaders } = await import('#/cli/sub/server/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('`kimi server kill` carries the bearer token', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('passes the resolved token to requestShutdown', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBe('tok-123'); + }); + + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + const url = buildWebUrl('http://127.0.0.1:58627', 'abc123'); + expect(url).toBe('http://127.0.0.1:58627/#token=abc123'); + const parsed = new URL(url); + expect(parsed.hash).toBe('#token=abc123'); + // The token is client-side only: it must NOT appear in the path or query + // (which WOULD be sent to the server and logged). + expect(parsed.pathname).not.toContain('abc123'); + expect(parsed.search).not.toContain('abc123'); + }); + + it('normalizes a trailing slash', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe( + 'http://127.0.0.1:58627/#token=t', + ); + }); +}); + +describe('accessUrlLines', () => { + it('returns Local + Network lines for a wildcard bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [ + { address: '192.168.1.5', family: 'IPv4' }, + ]); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://localhost:58627/#token=tok' }, + { label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' }, + ]); + }); + + it('returns a single Local line for a loopback bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('127.0.0.1', 58627, 'tok'); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' }, + ]); + }); + + it('returns a single URL line for a specific host (no token)', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('192.168.1.5', 58627, undefined); + expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]); + }); + + it('splitTokenFragment splits off the #token= fragment', async () => { + const { splitTokenFragment } = await import('#/cli/sub/server/access-urls'); + expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']); + expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']); + }); +}); + +describe('`kimi web` / `server run --open` token fragment (M5.5)', () => { + it('opens the Web UI URL with the token fragment when a token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-xyz', + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => undefined, + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server rotate-token`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a new token to server.token and prints it', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(token.length).toBeGreaterThan(20); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(token); + }); + + it('re-prints the access links with the new token when a server is running', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live lock pointing at this (alive) process so getLiveLock() finds + // the running server and the command can re-print its links. + mkdirSync(join(dir, 'server'), { recursive: true }); + writeSync( + join(dir, 'server', 'lock'), + JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + port: 58627, + host: '127.0.0.1', + }), + ); + + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`); + // Token line sits between the note and the links. + expect(stdout.indexOf('picks up the new token')).toBeLessThan( + stdout.indexOf('New server token'), + ); + expect(stdout.indexOf('New server token')).toBeLessThan( + stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`), + ); + }); +}); + +describe('formatHostForUrl', () => { + it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => { + const { formatHostForUrl } = await import('#/cli/sub/server/networks'); + expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5'); + expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]'); + }); +}); + +describe('filterDisplayAddresses', () => { + it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => { + const { filterDisplayAddresses } = await import('#/cli/sub/server/networks'); + const out = filterDisplayAddresses([ + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '192.168.1.5', family: 'IPv4' }, + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: 'fe80::1', family: 'IPv6' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + expect(out).toEqual([ + { address: '192.168.1.5', family: 'IPv4' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + }); +}); + +// Silence vi import for cases where the file is built before tests reference vi. +void vi; diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts new file mode 100644 index 000000000..8b0655862 --- /dev/null +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the CLI telemetry bootstrap helpers, focusing on the + * `kimi web` / `kimi server run` host wiring added in `cli/telemetry.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + initializeTelemetry: vi.fn(), + createKimiDeviceId: vi.fn(() => 'device-123'), + resolveKimiHome: vi.fn(() => '/home/.kimi-code'), + resolveConfigPath: vi.fn(() => '/home/.kimi-code/config.toml'), + loadRuntimeConfigSafe: vi.fn( + (): { + config: { defaultModel?: string; telemetry?: boolean }; + fileError: Error | undefined; + } => ({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }), + ), + getCachedAccessToken: vi.fn(async () => 'tok'), +})); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + setTelemetryContext: vi.fn(), + track: vi.fn(), + withTelemetryContext: vi.fn(), +})); + +vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + resolveKimiHome: mocks.resolveKimiHome, + resolveConfigPath: mocks.resolveConfigPath, + loadRuntimeConfigSafe: mocks.loadRuntimeConfigSafe, + KimiAuthFacade: vi.fn(function () { + return { getCachedAccessToken: mocks.getCachedAccessToken }; + }), + }; +}); + +describe('initializeServerTelemetry', () => { + beforeEach(() => { + mocks.initializeTelemetry.mockClear(); + mocks.loadRuntimeConfigSafe.mockClear(); + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }); + }); + + it('configures the sink with ui_mode="web" and the CLI product identity', async () => { + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + const client = initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + appName: 'kimi-code-cli', + version: '1.2.3', + uiMode: 'web', + model: 'kimi-k2', + enabled: true, + deviceId: 'device-123', + homeDir: '/home/.kimi-code', + }), + ); + // The returned client wraps the module functions so core + the host share + // the same underlying client. + expect(client).toEqual( + expect.objectContaining({ + track: expect.any(Function), + withContext: expect.any(Function), + setContext: expect.any(Function), + }), + ); + }); + + it('disables telemetry when config.toml sets telemetry = false', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: false }, + fileError: undefined, + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('degrades to enabled with no model when config is unreadable', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: {}, + fileError: new Error('bad toml'), + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, model: undefined }), + ); + }); +}); diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts index b7760fb84..3009c6709 100644 --- a/apps/kimi-code/test/cli/update/cache.test.ts +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -57,6 +57,7 @@ describe('update cache', () => { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: '0.5.0', + manifest: null, } as const; await writeUpdateCache(cache); @@ -64,6 +65,68 @@ describe('update cache', () => { expect(getUpdateStateFile()).toBe(join(dir, 'updates', 'latest.json')); await expect(readUpdateCache()).resolves.toEqual(cache); }); + + it('writes and reads back a cache carrying a rollout manifest', async () => { + const cache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { + version: '0.5.0', + publishedAt: '2026-04-23T07:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + } as const; + + await writeUpdateCache(cache); + + await expect(readUpdateCache()).resolves.toEqual(cache); + }); + + it('reads a legacy cache file without a manifest field as manifest null', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: null, + }); + }); + + it('keeps latest and treats a malformed manifest field as null', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { version: 'not-semver', publishedAt: 'nope', rollout: 'bad' }, + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: null, + }); + }); }); describe('update install state', () => { diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 3127bd71d..7eba81080 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { return vi.fn(async () => ({ @@ -19,11 +19,44 @@ function mockFetchStatus(status: number): typeof fetch { })) as unknown as typeof fetch; } +type Route = { readonly status?: number; readonly body?: string } | Error; + +/** URL-routed fetch mock: unrouted URLs return 404. */ +function mockRoutedFetch(routes: Record<string, Route>): typeof fetch { + return vi.fn(async (input: string | URL) => { + const route = routes[String(input)]; + if (route === undefined) { + return { ok: false, status: 404, text: async () => '' }; + } + if (route instanceof Error) throw route; + const status = route.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => route.body ?? '', + }; + }) as unknown as typeof fetch; +} + +const MANIFEST_BODY = JSON.stringify({ + schemaVersion: 1, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}); + describe('fetchLatestVersionFromCdn', () => { it('returns the trimmed semver returned by CDN /latest', async () => { const f = mockFetchOk(' 0.5.0\n'); await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); - expect(f).toHaveBeenCalledWith(KIMI_CODE_CDN_LATEST_URL); + expect(f).toHaveBeenCalledWith( + KIMI_CODE_CDN_LATEST_URL, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); it('throws when response is non-2xx', async () => { @@ -47,3 +80,154 @@ describe('fetchLatestVersionFromCdn', () => { await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); }); }); + +describe('fetchLatestFromCdn', () => { + it('parses latest.json and returns the manifest', async () => { + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '2.0.0', + manifest: { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + }); + expect(f).toHaveBeenCalledWith( + KIMI_CODE_CDN_LATEST_JSON_URL, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(f).toHaveBeenCalledTimes(1); + }); + + it('ignores unknown manifest fields (lenient parsing)', async () => { + const body = JSON.stringify({ + schemaVersion: 99, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + futureField: { nested: true }, + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest).toEqual({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + }); + }); + + it('defaults a missing rollout to an empty plan (fully rolled out)', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest?.rollout).toEqual([]); + }); + + const fallbackCases: ReadonlyArray<readonly [string, Route]> = [ + ['latest.json is missing (HTTP 404)', { status: 404 }], + ['latest.json fetch throws', new Error('network down')], + ['body is not valid JSON', { body: 'not json {' }], + ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], + ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], + ['a batch percent is out of range', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }], + ['a batch delay is negative', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }], + ]; + + for (const [name, route] of fallbackCases) { + it(`falls back to plain /latest when ${name}`, async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: route, + [KIMI_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + }); + } + + it('throws when both latest.json and plain /latest fail', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, + [KIMI_CODE_CDN_LATEST_URL]: { status: 500 }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); + }); + + it('propagates the plain /latest error when the fallback also breaks', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), + [KIMI_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); + }); + + it('falls back to plain /latest when latest.json hangs past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (input: string | URL, init?: RequestInit) => { + if (String(input) === KIMI_CODE_CDN_LATEST_JSON_URL) { + return new Promise<Response>((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + } + if (String(input) === KIMI_CODE_CDN_LATEST_URL) { + return { ok: true, status: 200, text: async () => '1.9.0\n' }; + } + return { ok: false, status: 404, text: async () => '' }; + }) as unknown as typeof fetch; + + const result = fetchLatestFromCdn(f); + await vi.advanceTimersByTimeAsync(3_000); + + await expect(result).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects when plain /latest also hangs past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (_input: string | URL, init?: RequestInit) => { + return new Promise<Response>((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + }) as unknown as typeof fetch; + + const result = fetchLatestFromCdn(f); + const expectation = expect(result).rejects.toThrow(/aborted/); + await vi.advanceTimersByTimeAsync(6_000); + + await expectation; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 33c4d42a3..4bc79289b 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -15,8 +15,14 @@ import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; import type * as RefreshModule from '#/cli/update/refresh'; +import type * as RolloutModule from '#/cli/update/rollout'; import { detectInstallSource } from '#/cli/update/source'; -import { emptyUpdateCache, type UpdateCache, type UpdateInstallState } from '#/cli/update/types'; +import { + emptyUpdateCache, + type UpdateCache, + type UpdateInstallState, + type UpdateManifest, +} from '#/cli/update/types'; import type { TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ @@ -28,6 +34,8 @@ const mocks = vi.hoisted(() => ({ detectInstallSource: vi.fn(), promptForInstallChoice: vi.fn(), refreshUpdateCache: vi.fn(), + resolveUpdateDeviceId: vi.fn(), + appendRolloutDecisionLog: vi.fn(), spawn: vi.fn(), })); @@ -81,6 +89,16 @@ vi.mock('../../../src/cli/update/refresh', async () => { }; }); +vi.mock('../../../src/cli/update/rollout', async () => { + const actual = await vi.importActual<typeof RolloutModule>('../../../src/cli/update/rollout.js'); + return { + ...actual, + resolveUpdateDeviceId: mocks.resolveUpdateDeviceId, + // Stubbed so preflight tests never write a real rollout.log. + appendRolloutDecisionLog: mocks.appendRolloutDecisionLog, + }; +}); + vi.mock('node:child_process', async () => { const actual = await vi.importActual<typeof ChildProcess>('node:child_process'); return { @@ -94,9 +112,43 @@ function cacheWith(version: string): UpdateCache { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: version, + manifest: null, }; } +function manifestFor(version: string, overrides: Partial<UpdateManifest> = {}): UpdateManifest { + return { + version, + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + ...overrides, + }; +} + +function cacheWithManifest(manifest: UpdateManifest): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: manifest.version, + manifest, + }; +} + +/** Every bucket delayed by 24h and the clock just started: nobody is eligible. */ +function heldForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }); +} + +/** Every bucket immediate and publishedAt long past: everybody is eligible. */ +function releasedForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + rollout: [{ percent: 100, delaySeconds: 0 }], + }); +} + function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstallState { return { active: null, @@ -109,6 +161,7 @@ function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstal function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig { return { theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -177,6 +230,8 @@ describe('runUpdatePreflight', () => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); + mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ filePath: '/tmp/kimi-update-install.lock', release: vi.fn().mockResolvedValue(undefined), @@ -365,6 +420,28 @@ describe('runUpdatePreflight', () => { ); }); + it('pnpm-global on win32: spawns pnpm.cmd through a shell', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('pnpm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + 'pnpm.cmd', + ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit', shell: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('yarn-global: spawns yarn global add', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -524,6 +601,27 @@ describe('runUpdatePreflight', () => { })); }); + it('win32 background auto-update hides the console window', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledWith( + 'npm.cmd', + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('tracks and logs successful background update installs', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); @@ -774,12 +872,236 @@ describe('runUpdatePreflight', () => { const track = vi.fn(); await runUpdatePreflight('0.4.0', { ...options, track }); expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - current: '0.4.0', - latest: '0.5.0', + current_version: '0.4.0', + target_version: '0.5.0', decision: 'prompt-install', source: 'npm-global', })); }); + + describe('rollout gating', () => { + it('hides a cached update whose batch is not yet eligible', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + // The launch still refreshes the cache in the background so the device + // flips to eligible purely by time passing. + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + // Both checks of this launch are recorded in the rollout log. + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'held', + current: '0.4.0', + latest: '0.5.0', + bucket: expect.any(Number), + delaySeconds: 86_400, + eligibleAt: expect.any(String), + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'background-refresh', + reason: 'held', + })); + }); + + it('starts the background install once the device batch is eligible', async () => { + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'eligible', + target: '0.5.0', + })); + }); + + it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => { + disableAutoInstall(); + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + }); + + it('uses the refreshed manifest for rollout telemetry when the prompt target changes', async () => { + disableAutoInstall(); + const cached = cacheWithManifest(manifestFor('0.6.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 0 }], + })); + const refreshed = cacheWithManifest(manifestFor('0.7.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 43_200 }], + })); + mocks.readUpdateCache.mockResolvedValue(cached); + mocks.refreshUpdateCache.mockResolvedValue(refreshed); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.5.0', { ...options, track })).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.7.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.7.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 43_200, + rollout_from_manifest: true, + })); + }); + + it('suppresses the manual-command notice while a homebrew device batch is held', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not start a fresh-check background install while the refreshed manifest is held', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('stays silent when the user-visible refresh reveals a held newer version', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.6.0'))); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.7.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('KIMI_CODE_EXPERIMENTAL_FLAG bypasses the rollout: held devices still update', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bypassed: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'experimental', + target: '0.5.0', + })); + }); + + it('KIMI_CODE_NO_AUTO_UPDATE still wins over the experimental flag', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1'); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.5.0'))); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('treats any plan older than 24h as fully rolled out', async () => { + disableAutoInstall(); + const staleRollout = manifestFor('0.5.0', { + publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(), + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + }); + }); }); describe('spawnForSource native', () => { diff --git a/apps/kimi-code/test/cli/update/refresh.test.ts b/apps/kimi-code/test/cli/update/refresh.test.ts index 16a18d2f3..ceb1306f7 100644 --- a/apps/kimi-code/test/cli/update/refresh.test.ts +++ b/apps/kimi-code/test/cli/update/refresh.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; import { refreshUpdateCache } from '#/cli/update/refresh'; +import type { UpdateManifest } from '#/cli/update/types'; + +const MANIFEST: UpdateManifest = { + version: '0.5.0', + publishedAt: '2026-05-20T12:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}; describe('refreshUpdateCache', () => { - it('writes a fresh cache on successful fetch', async () => { + it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => '0.5.0', + fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -15,12 +26,26 @@ describe('refreshUpdateCache', () => { source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: MANIFEST, }); - expect(writeCache).toHaveBeenCalledWith({ + expect(writeCache).toHaveBeenCalledWith(result); + }); + + it('writes a null manifest when the fetch fell back to plain text', async () => { + const writeCache = vi.fn(async () => {}); + const result = await refreshUpdateCache({ + fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), + writeCache, + now: () => new Date('2026-05-20T12:34:56.000Z'), + }); + + expect(result).toEqual({ source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: null, }); + expect(writeCache).toHaveBeenCalledWith(result); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { diff --git a/apps/kimi-code/test/cli/update/rollout.test.ts b/apps/kimi-code/test/cli/update/rollout.test.ts new file mode 100644 index 000000000..615906148 --- /dev/null +++ b/apps/kimi-code/test/cli/update/rollout.test.ts @@ -0,0 +1,366 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + isRolloutEligible, + MAX_ROLLOUT_DELAY_SECONDS, + rolloutBucket, + rolloutDelayForBucket, + rolloutDelaySeconds, + resolveUpdateDeviceId, + selectPassiveUpdateTarget, +} from '#/cli/update/rollout'; +import type { RolloutBatch, UpdateManifest } from '#/cli/update/types'; + +const STANDARD_ROLLOUT: readonly RolloutBatch[] = [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, +]; + +const PUBLISHED_AT = '2026-06-12T00:00:00.000Z'; +const PUBLISHED_AT_MS = Date.parse(PUBLISHED_AT); + +function makeManifest(overrides: Partial<UpdateManifest> = {}): UpdateManifest { + return { + version: '2.0.0', + publishedAt: PUBLISHED_AT, + rollout: STANDARD_ROLLOUT, + ...overrides, + }; +} + +function secondsAfterPublish(seconds: number): Date { + return new Date(PUBLISHED_AT_MS + seconds * 1000); +} + +describe('rolloutBucket', () => { + it('is deterministic and within 0-99', () => { + for (let i = 0; i < 200; i++) { + const bucket = rolloutBucket(`device-${i}`, '2.0.0'); + expect(bucket).toBe(rolloutBucket(`device-${i}`, '2.0.0')); + expect(bucket).toBeGreaterThanOrEqual(0); + expect(bucket).toBeLessThan(100); + expect(Number.isInteger(bucket)).toBe(true); + } + }); + + it('matches pinned vectors (regression guard for the hash layout)', () => { + expect(rolloutBucket('device-a', '1.0.0')).toBe(65); + expect(rolloutBucket('device-b', '1.0.0')).toBe(76); + expect(rolloutBucket('fixed-device', '2.0.0')).toBe(26); + }); + + it('reshuffles buckets when the version changes', () => { + expect(rolloutBucket('device-a', '1.0.1')).toBe(79); + expect(rolloutBucket('device-a', '1.0.1')).not.toBe(rolloutBucket('device-a', '1.0.0')); + }); +}); + +describe('rolloutDelayForBucket', () => { + it('maps buckets to batches at the exact boundaries', () => { + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 0)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 29)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 30)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 59)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 60)).toBe(86_400); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 99)).toBe(86_400); + }); + + it('clamps oversized delays to 24h', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 100, delaySeconds: 999_999 }]; + expect(rolloutDelayForBucket(rollout, 50)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('assigns buckets not covered by the plan to the slowest cohort', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 30, delaySeconds: 0 }]; + expect(rolloutDelayForBucket(rollout, 29)).toBe(0); + expect(rolloutDelayForBucket(rollout, 30)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + expect(rolloutDelayForBucket(rollout, 99)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('tolerates percents summing past 100', () => { + const rollout: readonly RolloutBatch[] = [ + { percent: 60, delaySeconds: 0 }, + { percent: 60, delaySeconds: 43_200 }, + ]; + expect(rolloutDelayForBucket(rollout, 59)).toBe(0); + expect(rolloutDelayForBucket(rollout, 99)).toBe(43_200); + }); + + it('treats an empty plan as fully rolled out', () => { + expect(rolloutDelayForBucket([], 0)).toBe(0); + expect(rolloutDelayForBucket([], 99)).toBe(0); + }); +}); + +describe('rolloutDelaySeconds', () => { + it('splits 10k devices roughly 30/30/40 across the standard plan', () => { + const counts = new Map<number, number>([ + [0, 0], + [43_200, 0], + [86_400, 0], + ]); + const manifest = makeManifest(); + for (let i = 0; i < 10_000; i++) { + const delay = rolloutDelaySeconds(manifest, `device-${i}`); + counts.set(delay, (counts.get(delay) ?? 0) + 1); + } + expect(counts.get(0)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(0)).toBeLessThanOrEqual(3_300); + expect(counts.get(43_200)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(43_200)).toBeLessThanOrEqual(3_300); + expect(counts.get(86_400)).toBeGreaterThanOrEqual(3_700); + expect(counts.get(86_400)).toBeLessThanOrEqual(4_300); + }); +}); + +describe('isRolloutEligible', () => { + const delayedForEveryone = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 43_200 }], + }); + + it('is not eligible before publishedAt + delay', () => { + const justBefore = new Date(PUBLISHED_AT_MS + 43_200 * 1000 - 1); + expect(isRolloutEligible(delayedForEveryone, 'device-a', justBefore)).toBe(false); + }); + + it('is eligible exactly at publishedAt + delay', () => { + expect(isRolloutEligible(delayedForEveryone, 'device-a', secondsAfterPublish(43_200))).toBe( + true, + ); + }); + + it('is not eligible while publishedAt is still in the future', () => { + const manifest = makeManifest({ rollout: [] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-3_600))).toBe(false); + }); + + it('is always eligible 24h after publish regardless of the plan', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 999_999 }] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(86_400))).toBe(true); + }); + + it('fails open when publishedAt cannot be parsed', () => { + const manifest = makeManifest({ publishedAt: 'not-a-date' }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-999_999))).toBe(true); + }); +}); + +describe('selectPassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('falls back to plain latest when manifest is null', () => { + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + expect(selectPassiveUpdateTarget('1.0.0', null, null, 'device-a', now)).toBeNull(); + }); + + it('returns null when the manifest version is not newer', () => { + const manifest = makeManifest({ rollout: [] }); + expect(selectPassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + expect(selectPassiveUpdateTarget('3.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); + + it('returns the target once the device batch is eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + }); + + it('hides the target while the device batch is not yet eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); +}); + +describe('decidePassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('reports no-latest when nothing is known yet', () => { + const decision = decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now); + expect(decision).toMatchObject({ target: null, reason: 'no-latest' }); + }); + + it('reports not-newer when the known version is not an upgrade', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', null, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + const manifest = makeManifest({ rollout: [] }); + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + }); + + it('reports no-manifest legacy visibility when only plain latest is known', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('reports held with bucket, delay and eligibleAt while the batch is gated', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: null, + reason: 'held', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 86_400, + eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), + }); + }); + + it('reports eligible once the batch delay has passed', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 43_200 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(43_200), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'eligible', + delaySeconds: 43_200, + }); + }); +}); + +describe('appendRolloutDecisionLog', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-log-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('appends one JSON line per decision', async () => { + const file = join(dir, 'updates', 'rollout.log'); + await appendRolloutDecisionLog({ phase: 'startup-cache', reason: 'held' }, file); + await appendRolloutDecisionLog({ phase: 'prompt-refresh', reason: 'eligible' }, file); + + const lines = readFileSync(file, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? '')).toMatchObject({ phase: 'startup-cache', reason: 'held' }); + expect(JSON.parse(lines[1] ?? '')).toMatchObject({ phase: 'prompt-refresh', reason: 'eligible' }); + }); + + it('resets the file once it grows past the size cap', async () => { + const file = join(dir, 'rollout.log'); + writeFileSync(file, 'x'.repeat(300 * 1024), 'utf-8'); + await appendRolloutDecisionLog({ reason: 'eligible' }, file); + + const content = readFileSync(file, 'utf-8'); + expect(content.length).toBeLessThan(1024); + expect(JSON.parse(content.trim())).toMatchObject({ reason: 'eligible' }); + }); + + it('never throws on unwritable paths', async () => { + await expect( + appendRolloutDecisionLog({ reason: 'held' }, '/dev/null/nope/rollout.log'), + ).resolves.toBeUndefined(); + }); +}); + +describe('resolveUpdateDeviceId', () => { + const originalEnv = { ...process.env }; + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-device-id-')); + process.env['KIMI_CODE_HOME'] = dir; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + }); + + it('does not create the telemetry device id file when one is missing', () => { + const deviceId = resolveUpdateDeviceId(); + + expect(deviceId).toMatch(/^[0-9a-f-]+$/); + expect(existsSync(join(dir, 'device_id'))).toBe(false); + }); + + it('reuses an existing telemetry device id without rewriting it', () => { + writeFileSync(join(dir, 'device_id'), 'existing-device-id', 'utf-8'); + + expect(resolveUpdateDeviceId()).toBe('existing-device-id'); + expect(readFileSync(join(dir, 'device_id'), 'utf-8')).toBe('existing-device-id'); + }); +}); + +describe('experimental flag bypass', () => { + const now = secondsAfterPublish(60); + const heldManifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + + it('bypasses a held rollout and reports experimental', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', heldManifest, 'device-a', now, true); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('still reports not-newer / no-latest under bypass', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + expect(decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'no-latest', + }); + }); + + it('marks plain-latest visibility as experimental when bypassing', () => { + expect(decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now, true)).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + }); + }); +}); + +describe('isRolloutBypassedByExperimentalEnv', () => { + it('is on for the usual truthy values of KIMI_CODE_EXPERIMENTAL_FLAG', () => { + for (const value of ['1', 'true', 'YES', ' on ']) { + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: value })).toBe(true); + } + }); + + it('is off when unset, blank, or falsy', () => { + expect(isRolloutBypassedByExperimentalEnv({})).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '0' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: 'off' })).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 8995f07cf..31b53b1d9 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -4,11 +4,15 @@ import { handleUpgrade } from '#/cli/sub/upgrade'; import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; import type { InstallSource, UpdateCache } from '#/cli/update/types'; -function cacheWith(version: string | null): UpdateCache { +function cacheWith( + version: string | null, + manifest: UpdateCache['manifest'] = null, +): UpdateCache { return { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: version, + manifest, }; } @@ -34,6 +38,7 @@ function captureOutput(): { function createDeps(overrides: { readonly latest?: string | null; + readonly manifest?: UpdateCache['manifest']; readonly source?: InstallSource; readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise<InstallPromptChoiceValue>; @@ -48,7 +53,9 @@ function createDeps(overrides: { ) => Promise<void>>().mockResolvedValue(undefined); return { - refreshUpdateCache: vi.fn().mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0')), + refreshUpdateCache: vi + .fn() + .mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0', overrides.manifest ?? null)), detectInstallSource: vi.fn().mockResolvedValue(overrides.source ?? 'npm-global'), promptForInstallChoice: overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), @@ -206,4 +213,23 @@ describe('handleUpgrade', () => { })); expect(stderr.join('')).toContain('error: failed to check for updates: cdn unavailable'); }); + + it('ignores rollout gating: installs the latest version while every batch is still held', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + // Published seconds ago with every device delayed by 24h — passive + // update surfaces would hide this version, manual upgrade must not. + manifest: { + version: '0.5.0', + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }, + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + }); }); diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts index f17240eff..6729cb377 100644 --- a/apps/kimi-code/test/cli/version.test.ts +++ b/apps/kimi-code/test/cli/version.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -15,7 +15,7 @@ describe('cli version helpers', () => { const pkgPath = getHostPackageJsonPath(); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - expect(pkgPath.endsWith('/apps/kimi-code/package.json')).toBe(true); + expect(pkgPath.endsWith(join('apps', 'kimi-code', 'package.json'))).toBe(true); expect(getHostPackageRoot()).toBe(dirname(pkgPath)); expect(getVersion()).toBe(pkg.version); }); diff --git a/apps/kimi-code/test/cli/vis.test.ts b/apps/kimi-code/test/cli/vis.test.ts new file mode 100644 index 000000000..b611e6a37 --- /dev/null +++ b/apps/kimi-code/test/cli/vis.test.ts @@ -0,0 +1,114 @@ +/** + * `kimi vis` + * + * Verifies the CLI layer for the session visualizer: home + auto-port + * resolution, browser open vs `--no-open`, and the session deep-link path. + * Uses injected deps so no real port is bound and the real vis server is + * never started. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { handleVis, type VisDeps } from '#/cli/sub/vis'; + +function makeDeps(over: Partial<VisDeps> = {}): { + deps: VisDeps; + opened: string[]; + out: string[]; +} { + const opened: string[] = []; + const out: string[] = []; + const deps: VisDeps = { + getHomeDir: () => '/home/k', + startVisServer: vi.fn(async (o) => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close: async () => {}, + _opts: o, + })) as unknown as VisDeps['startVisServer'], + openUrl: async (u: string) => { + opened.push(u); + }, + waitForShutdown: async () => {}, + stdout: { + write: (s: string) => { + out.push(s); + return true; + }, + }, + stderr: { write: () => true }, + exit: vi.fn() as unknown as VisDeps['exit'], + ...over, + }; + return { deps, opened, out }; +} + +describe('handleVis', () => { + it('starts the server with the home dir + auto port and opens the browser', async () => { + const { deps, opened, out } = makeDeps(); + await handleVis(deps, { open: true }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 0 }), + ); + expect(opened).toEqual(['http://127.0.0.1:41234/']); + expect(out.join('')).toContain('http://127.0.0.1:41234/'); + }); + + it('does not open the browser when open is false', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: false }); + expect(opened).toEqual([]); + }); + + it('deep-links to a session when sessionId is given', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: true, sessionId: 'sess_abc' }); + expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc'); + }); + + it('uses the explicit port when provided', async () => { + const { deps } = makeDeps(); + await handleVis(deps, { open: false, port: 4321 }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 4321 }), + ); + }); + + it('closes the server after shutdown', async () => { + const close = vi.fn(async () => {}); + const { deps } = makeDeps({ + startVisServer: vi.fn(async () => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close, + })) as unknown as VisDeps['startVisServer'], + }); + await handleVis(deps, { open: false }); + expect(close).toHaveBeenCalledOnce(); + }); + + it('reports a clean error and exits when the server fails to start', async () => { + const errored: string[] = []; + const { deps, opened } = makeDeps({ + startVisServer: vi.fn(async () => { + throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321'); + }) as unknown as VisDeps['startVisServer'], + stderr: { + write: (s: string) => { + errored.push(s); + return true; + }, + }, + waitForShutdown: vi.fn(async () => {}), + }); + await handleVis(deps, { open: true, port: 4321 }); + expect(errored.join('')).toContain('Failed to start kimi vis'); + expect(errored.join('')).toContain('EADDRINUSE'); + expect(deps.exit).toHaveBeenCalledWith(1); + // Nothing past the failed start should run. + expect(opened).toEqual([]); + expect(deps.waitForShutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts new file mode 100644 index 000000000..8903d1626 --- /dev/null +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('uploadArchive', () => { + it('requests upload parts, PUTs each part, and completes with etags', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + expect(api.createUploadUrl).toHaveBeenCalledWith({ + feedbackId: 3, + filename: 'repo.zip', + size: 5, + sha256: 'hash', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://example.test/part1'); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(ReadableStream); + expect((init as { duplex?: string }).duplex).toBe('half'); + expect(new Headers(init.headers).get('content-length')).toBe('5'); + // Drain the stream so the underlying file handle is released. + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('uses the backend-provided part upload method', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-method-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'POST', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('aborts a stalled part PUT and does not mark upload complete', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + new Promise<Response>((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, + { once: true }, + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 25, maxRetries: 0 }, + ); + const expectation = expect(upload).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(25); + await expectation; + expect(api.completeUpload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('retries a failed part and completes once it succeeds', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-retry-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + let attempt = 0; + const fetchMock = vi.fn(async () => { + attempt += 1; + if (attempt === 1) return new Response('server error', { status: 500 }); + return new Response('', { status: 200, headers: { ETag: '"etag-1"' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 10_000 }, + ); + await vi.advanceTimersByTimeAsync(1_000); + await upload; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); +}); + +describe('packageCodebase', () => { + it('rejects empty codebase archives instead of uploading an empty zip', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-codebase.zip'); + try { + await expect( + packageCodebase( + { + root: tmpdir(), + files: [], + fingerprint: 'empty-codebase', + usedGitIgnore: false, + }, + archivePath, + ), + ).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + + +describe('scanCodebase filtering', () => { + it('rejects when the scan signal is already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); + const controller = new AbortController(); + controller.abort(); + try { + await expect(scanCodebase(root, { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips dependency and build directories outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-no-git-')); + try { + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n'); + await writeFile(join(root, 'dist', 'bundle.js'), 'built\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(false); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files even when tracked by git', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); + try { + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, '.envrc'), 'export AWS_SECRET_ACCESS_KEY=secret\n'); + await writeFile(join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=secret\n'); + await writeFile(join(root, '.yarnrc.yml'), 'npmAuthToken: secret\n'); + await writeFile(join(root, 'id_rsa'), 'private-key\n'); + await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + const paths = scan.files.map((file) => file.path); + expect(paths).toContain('app.ts'); + expect(paths).not.toContain('.env'); + expect(paths).not.toContain('.envrc'); + expect(paths).not.toContain('.npmrc'); + expect(paths).not.toContain('.yarnrc.yml'); + expect(paths).not.toContain('id_rsa'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files by glob outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-sensitive-')); + try { + await mkdir(join(root, '.ssh')); + await writeFile(join(root, '.env.production'), 'SECRET=1\n'); + await writeFile(join(root, 'tls.pem'), 'cert\n'); + await writeFile(join(root, '.ssh', 'config'), 'Host *\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips individual files larger than the per-file limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-large-file-')); + try { + await writeFile(join(root, 'big.bin'), randomBytes(256)); + await writeFile(join(root, 'small.txt'), 'hello\n'); + + const scan = await scanCodebase(root, { limits: { maxFileSize: 128 } }); + expect(scan.files.map((file) => file.path)).toEqual(['small.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips tracked files that were deleted from the working tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-deleted-')); + try { + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + await writeFile(join(root, 'deleted.ts'), 'export const gone = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + // Remove only from the working tree; the index still lists it, so + // `git ls-files` reports a path that no longer exists on disk. + await rm(join(root, 'deleted.ts')); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when file count reaches the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-limit-')); + try { + await writeFile(join(root, 'a.txt'), 'a\n'); + await writeFile(join(root, 'b.txt'), 'b\n'); + await writeFile(join(root, 'c.txt'), 'c\n'); + + const scan = await scanCodebase(root, { limits: { maxFiles: 2 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'file-count', limit: 2 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when cumulative file size reaches the archive limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-total-size-')); + try { + await writeFile(join(root, 'a.txt'), 'a'.repeat(100)); + await writeFile(join(root, 'b.txt'), 'b'.repeat(100)); + await writeFile(join(root, 'c.txt'), 'c'.repeat(100)); + + // 250 bytes fits any two files (200) but not the third (300). + const scan = await scanCodebase(root, { limits: { maxArchiveSize: 250 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'total-size', limit: 250 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe('removeStaleFeedbackUploads', () => { + it('removes archive dirs older than the cutoff and keeps recent ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-uploads-gc-')); + try { + const staleDir = join(root, 'stale'); + const freshDir = join(root, 'fresh'); + await mkdir(staleDir); + await mkdir(freshDir); + await writeFile(join(staleDir, 'repo.zip'), 'old'); + await writeFile(join(freshDir, 'repo.zip'), 'new'); + + const now = Date.now(); + const twoDaysAgoSec = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + await utimes(staleDir, twoDaysAgoSec, twoDaysAgoSec); + + await removeStaleFeedbackUploads({ now, dir: root }); + + await expect(stat(staleDir)).rejects.toThrow(); + await expect(stat(freshDir)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('is a no-op when the cache dir does not exist', async () => { + const missing = join(tmpdir(), 'feedback-uploads-gc-missing-' + String(Date.now())); + await expect(removeStaleFeedbackUploads({ dir: missing })).resolves.toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index f450b099c..da70d5309 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -35,7 +35,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -55,7 +54,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); const out = render(c); @@ -69,7 +67,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -85,7 +82,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -104,7 +100,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, runMigration: async (input) => { captured = input; return makeReport(); @@ -123,7 +118,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 1365 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1: Migrate now -> ask2 @@ -140,7 +134,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan({ totalSessions: 0 }), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c.handleInput('\r'); // ask1 -> ask2 @@ -155,7 +148,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, onComplete: () => {}, }); @@ -171,7 +163,6 @@ describe('MigrationScreenComponent — ask phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, runMigration: async (input) => { captured = input; @@ -191,7 +182,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // expose progress rendering via the test hook (see Step 5.2) @@ -211,7 +201,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, skipDecisionStep: true, // A migration that never settles keeps the screen in the progress // phase so the spinner animation can be observed. @@ -236,7 +225,6 @@ describe('MigrationScreenComponent — progress phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testEnterProgress(); @@ -312,7 +300,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport()); @@ -327,7 +314,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -361,7 +347,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { result = r; }, @@ -377,7 +362,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); // config skipped (e.g. a malformed legacy config.toml). @@ -413,7 +397,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -454,7 +437,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -496,7 +478,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); @@ -511,7 +492,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult( @@ -544,7 +524,6 @@ describe('MigrationScreenComponent — result phase', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, }); c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); @@ -561,7 +540,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: (r) => { onCompleteResult = r; }, @@ -583,7 +561,6 @@ describe('MigrationScreenComponent — execution wiring', () => { plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', - colors: darkColors, onComplete: () => {}, runMigration: async () => { throw new Error('boom'); diff --git a/apps/kimi-code/test/native/web-assets.test.ts b/apps/kimi-code/test/native/web-assets.test.ts new file mode 100644 index 000000000..abe3801fe --- /dev/null +++ b/apps/kimi-code/test/native/web-assets.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getNativeWebAssetsDir, + getWebAssetCacheRoot, + WEB_ASSET_MANIFEST_VERSION, + type WebAssetManifest, + type WebAssetSource, +} from '#/native/web-assets'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeWebAssets(files: Record<string, string>): { + manifest: WebAssetManifest; + source: WebAssetSource; +} { + const manifest: WebAssetManifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `web/test-target/dist-web/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map<string, Buffer>([ + ['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `web/test-target/dist-web/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +describe('web assets', () => { + it('extracts embedded web assets into a dist-web cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '<div id="app"></div>\n', + 'assets/app.js': 'console.log("ok");\n', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' })); + expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('<div id="app"></div>\n'); + expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe( + 'console.log("ok");\n', + ); + expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '<html></html>', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + writeFileSync(join(webDir ?? '', 'index.html'), 'broken'); + + const repairedDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(webDir); + expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe('<html></html>'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA web asset source is available', () => { + expect(getNativeWebAssetsDir({ source: null })).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index c96b642e6..980f1c771 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -41,7 +41,7 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); - expect(names).toContain('koffi'); + expect(names).toContain('@moonshot-ai/pi-tui'); }); it('picks the right clipboard subpackage per target', () => { @@ -56,13 +56,23 @@ describe('resolveTargetDeps', () => { ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); }); - it('encodes koffi native file path with target triplet', () => { - const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); - expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); - const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); - expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); - const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); - expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); + it('encodes pi-tui native file path per target', () => { + const linuxPiTui = resolveTargetDeps('linux-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(linuxPiTui?.nativeFileRelatives).toEqual([]); + const macPiTui = resolveTargetDeps('darwin-x64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(macPiTui?.nativeFileRelatives).toEqual([ + 'native/darwin/prebuilds/darwin-x64/darwin-modifiers.node', + ]); + const winArmPiTui = resolveTargetDeps('win32-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(winArmPiTui?.nativeFileRelatives).toEqual([ + 'native/win32/prebuilds/win32-arm64/win32-console-mode.node', + ]); }); it('throws on unsupported target', () => { @@ -82,9 +92,9 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { - const koffi = nativeDeps.find((d) => d.id === 'koffi'); - expect(koffi?.collect).toBe('js-and-native-file'); - expect(koffi?.parent).toBe('pi-tui'); + it('has pi-tui (collect=native-file-only, no parent)', () => { + const piTui = nativeDeps.find((d) => d.id === 'pi-tui'); + expect(piTui?.collect).toBe('native-file-only'); + expect(piTui?.parent).toBe(null); }); }); diff --git a/apps/kimi-code/test/scripts/native/paths.test.ts b/apps/kimi-code/test/scripts/native/paths.test.ts index 80209f457..826ade0ba 100644 --- a/apps/kimi-code/test/scripts/native/paths.test.ts +++ b/apps/kimi-code/test/scripts/native/paths.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { @@ -18,6 +20,10 @@ import { SEA_SENTINEL_FUSE, } from '../../../scripts/native/paths.mjs'; +// paths.mjs builds every path with node:path.resolve (backslashes on Windows). +// Build expectations the same way so they match on every platform. +const p = (...segments: string[]): string => resolve(appRoot, ...segments); + describe('targetTriple', () => { it('returns platform-arch when env unset', () => { expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64'); @@ -49,27 +55,27 @@ describe('executableName', () => { describe('path helpers', () => { it('returns absolute intermediates dir under app root', () => { - expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`); + expect(nativeIntermediatesDir()).toBe(p('dist-native/intermediates')); }); it('returns absolute bin dir per target', () => { - expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`); + expect(nativeBinDir('darwin-arm64')).toBe(p('dist-native/bin/darwin-arm64')); }); it('returns absolute bin path with executable name', () => { expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( - `${appRoot}/dist-native/bin/darwin-arm64/kimi`, + p('dist-native/bin/darwin-arm64/kimi'), ); expect(nativeBinPath('win32-x64', 'win32')).toBe( - `${appRoot}/dist-native/bin/win32-x64/kimi.exe`, + p('dist-native/bin/win32-x64/kimi.exe'), ); }); it('returns intermediate artifact paths', () => { - expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.cjs`); - expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/kimi.blob`); + expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs')); + expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kimi.blob')); expect(nativeSeaConfigPath()).toBe( - `${appRoot}/dist-native/intermediates/sea-config.json`, + p('dist-native/intermediates/sea-config.json'), ); }); @@ -78,21 +84,21 @@ describe('path helpers', () => { }); it('returns native dist root', () => { - expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`); + expect(nativeDistRoot()).toBe(p('dist-native')); }); it('returns manifest dir for target', () => { expect(nativeManifestDir('darwin-arm64')).toBe( - `${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`, + p('dist-native/intermediates/native-assets/darwin-arm64'), ); }); it('returns artifacts dir', () => { - expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`); + expect(nativeArtifactsDir()).toBe(p('dist-native/artifacts')); }); it('returns smoke home', () => { - expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`); + expect(nativeSmokeHome()).toBe(p('dist-native/smoke-home')); }); it('has correct SEA sentinel fuse value', () => { diff --git a/apps/kimi-code/test/scripts/native/web-assets.test.ts b/apps/kimi-code/test/scripts/native/web-assets.test.ts new file mode 100644 index 000000000..2b5bfa920 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/web-assets.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectWebAssets, + webAssetManifestKey, + WEB_ASSET_MANIFEST_VERSION, +} from '../../../scripts/native/web-assets.mjs'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('collectWebAssets', () => { + it('collects dist-web files into deterministic SEA asset keys', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-')); + try { + mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<div id="app"></div>\n'); + writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n'); + + const { manifest, manifestJson, assets } = await collectWebAssets({ + appRoot, + target: 'test-target', + }); + + expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json'); + expect(manifest).toEqual({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: [ + { + assetKey: 'web/test-target/dist-web/assets/app.js', + relativePath: 'assets/app.js', + sha256: sha256('console.log("ok");\n'), + }, + { + assetKey: 'web/test-target/dist-web/index.html', + relativePath: 'index.html', + sha256: sha256('<div id="app"></div>\n'), + }, + ], + }); + expect(JSON.parse(manifestJson) as unknown).toEqual(manifest); + expect(assets).toEqual({ + 'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'), + 'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'), + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when dist-web has not been built', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-')); + try { + await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow( + /Kimi web build output was not found/, + ); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('keeps manifest JSON parseable and stable', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-')); + try { + mkdirSync(join(appRoot, 'dist-web'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<html></html>'); + + const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' }); + + expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe('<html></html>'); + expect(manifestJson.endsWith('\n')).toBe(true); + expect(JSON.parse(manifestJson)).toMatchObject({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index c8e13a305..0bcae749a 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -29,13 +29,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } @@ -48,6 +48,7 @@ function makeDriverWithTerminalProgress(): { const driver = new KimiTUI({} as never, makeStartupInput()) as unknown as ActivityDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); driver.state.terminal = { columns: 80, setProgress } as unknown as TUIState['terminal']; + driver.state.terminalState.supportsProgress = true; return { driver, state: driver.state, setProgress }; } @@ -101,6 +102,24 @@ describe('updateActivityPane terminal progress', () => { } }); + it('never emits terminal progress when the terminal does not support OSC 9;4', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + state.terminalState.supportsProgress = false; + + state.livePane = { ...state.livePane, mode: 'waiting' }; + driver.updateActivityPane(); + state.livePane = { ...state.livePane, mode: 'idle' }; + driver.updateActivityPane(); + + expect(setProgress).not.toHaveBeenCalled(); + expect(state.terminalState.progressActive).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('keeps compaction visible as terminal progress even though the pane is hidden', () => { const { driver, state, setProgress } = makeDriverWithTerminalProgress(); state.appState.isCompacting = true; diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts new file mode 100644 index 000000000..0fce5984a --- /dev/null +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -0,0 +1,605 @@ +import { describe, expect, it } from 'vitest'; + +import { + selectBannerState, + selectDisplayableBanner, + shouldDisplayBanner, +} from '#/tui/banner/banner-provider'; +import type { BannerState } from '#/tui/types'; + +describe('selectBannerState', () => { + const now = new Date('2026-06-15T12:00:00+08:00'); + + function expectAlwaysBanner( + result: BannerState | null, + expected: Pick<BannerState, 'tag' | 'mainText' | 'subText'>, + ): BannerState { + expect(result).not.toBeNull(); + const banner = result!; + expect(banner).toMatchObject({ ...expected, display: 'always' }); + expect(banner.key).toEqual(expect.any(String)); + expect(banner.ttlHours).toBeUndefined(); + return banner; + } + + it('returns the active banner when enabled and no time window is set', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: 'New', mainText: 'Active', subText: 'Details' }); + }); + + it('returns null when the active banner is outside its time window', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'Old', + banner_maintext: 'Expired', + banner_start_time: '2026-05-01T00:00:00+08:00', + banner_end_time: '2026-05-31T00:00:00+08:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('filters out the active banner when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'New', + banner_min_version: '0.15.0', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('picks a random enabled fallback when the active banner is not shown', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_title: 'Tip', banner_maintext: 'First' }, + { enabled: true, banner_title: 'Tip', banner_maintext: 'Second' }, + ], + }, + '0.14.0', + now, + () => 0.75, + ); + expectAlwaysBanner(result, { tag: 'Tip', mainText: 'Second', subText: null }); + }); + + it('filters out fallback entries when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_maintext: 'Old tip' }, + { enabled: true, banner_maintext: 'New tip', banner_min_version: '0.15.0' }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Old tip', subText: null }); + }); + + it('returns null when no enabled fallback entries exist', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: false, banner_maintext: 'Hidden' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null for malformed input fields', () => { + expect(selectBannerState({ weird: true }, '0.14.0', now, () => 0)).toBeNull(); + }); + + it('falls back to the fallback list when banner_enabled is missing', () => { + const result = selectBannerState( + { + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats an empty tag as null while still showing the banner', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: '', + banner_maintext: 'No tag', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'No tag', subText: null }); + }); + + it('makes the active banner unavailable when mainText is empty', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: '', + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats missing subtext as null', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Main only', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Main only', subText: null }); + }); + + it('treats empty time fields as always valid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Always on', + banner_start_time: '', + banner_end_time: null, + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Always on', subText: null }); + }); + + it('falls back to UTC when timestamps have no timezone', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'UTC fallback', + banner_start_time: '2026-06-15T04:00:00', + banner_end_time: '2026-06-15T20:00:00', + }, + '0.14.0', + now, + () => 0, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'UTC fallback', subText: null }); + }); + + it('returns null when the fallback list is empty', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null when the fallback list is missing', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('uses banner_id as the banner key when present', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ key: 'active-1', display: 'always' }); + }); + + it('generates a stable hash key when banner_id is missing', () => { + const json = { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }; + + const first = selectBannerState(json, '0.14.0', now, () => 0); + const second = selectBannerState(json, '0.14.0', now, () => 0); + const changedDisplay = selectBannerState( + { + ...json, + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(changedDisplay).not.toBeNull(); + expect(first!.key).toMatch(/^[0-9a-f]{32}$/); + expect(second!.key).toBe(first!.key); + expect(changedDisplay!.key).not.toBe(first!.key); + }); + + it('parses cooldown display and ttl hours', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'active-1', + display: 'cooldown', + ttlHours: 72, + }); + }); + + it('falls back to 24 hours when cooldown ttl is invalid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 0, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'cooldown', ttlHours: 24 }); + }); + + it('falls back to always for unknown display values', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: '24h', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'always' }); + expect(result?.ttlHours).toBeUndefined(); + }); + + it('supports fallback display and ttl fields', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-1', + banner_maintext: 'Fallback', + banner_display: 'cooldown', + banner_display_ttl_hours: 168, + }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'fallback-1', + display: 'cooldown', + ttlHours: 168, + }); + }); +}); + +describe('shouldDisplayBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + const banner: BannerState = { + key: 'always', + tag: null, + mainText: 'Always', + subText: null, + display: 'always', + }; + + it('returns true for always banners even when they were shown before', () => { + expect( + shouldDisplayBanner( + banner, + { + version: 1, + shown: { + always: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns true for once banners without a shown record', () => { + expect(shouldDisplayBanner({ ...banner, key: 'once', display: 'once' }, { version: 1, shown: {} }, now)).toBe( + true, + ); + }); + + it('returns false for once banners with a shown record', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('treats an invalid shown record as not shown', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: 'not-a-date' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns false during cooldown ttl', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('returns true at the cooldown ttl boundary', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('supports custom cooldown ttl values', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 1 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 168 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-09T12:00:01.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); +}); + +describe('selectDisplayableBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + it('falls back when the active once banner was already shown', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'once', + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + banner_display: 'once', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'once' }); + }); + + it('falls back when active cooldown is within ttl', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 1, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'always' }); + }); + + it('returns active cooldown after ttl instead of fallback', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 24, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'active', display: 'cooldown', ttlHours: 24 }); + }); + + it('randomly chooses only displayable fallback candidates', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-once', + banner_maintext: 'Fallback once', + banner_display: 'once', + }, + { + enabled: true, + banner_id: 'fallback-always', + banner_maintext: 'Fallback always', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + 'fallback-once': { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback-always', display: 'always' }); + }); +}); diff --git a/apps/kimi-code/test/tui/banner/state.test.ts b/apps/kimi-code/test/tui/banner/state.test.ts new file mode 100644 index 000000000..0824e6fd9 --- /dev/null +++ b/apps/kimi-code/test/tui/banner/state.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyBannerDisplayState, + readBannerDisplayState, + writeBannerDisplayState, +} from '#/tui/banner/state'; +import { getBannerStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-banner-state-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('banner display state cache', () => { + it('returns an empty state when the file is missing', async () => { + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state when the file is corrupt', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync(getBannerStateFile(), '{"broken"', 'utf-8'); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state for an unknown future version', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 2, + shown: {}, + }), + 'utf-8', + ); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('writes and reads back the state from cache/banner/state.json', async () => { + const state = { + version: 1 as const, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }; + + await writeBannerDisplayState(state); + + expect(getBannerStateFile()).toBe(join(dir, 'cache', 'banner', 'state.json')); + await expect(readBannerDisplayState()).resolves.toEqual(state); + }); + + it('drops invalid shown records when reading', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + invalid: { lastShownAt: 'not-a-date' }, + malformed: { shownAt: '2026-06-16T00:00:00.000Z' }, + }, + }), + 'utf-8', + ); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts new file mode 100644 index 000000000..0c3381a87 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleAddDirCommand } from '#/tui/commands/add-dir'; +import { dispatchInput, type SlashCommandHost } from '#/tui/commands/dispatch'; + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function makeHost(additionalDirs: readonly string[] = []) { + const state = { + appState: { + additionalDirs, + streamingPhase: 'idle', + isCompacting: false, + }, + }; + let mountedPanel: MountedPanel | null = null; + const session = { + id: 'session-1', + summary: { + additionalDirs, + }, + addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({ + additionalDirs: [...additionalDirs, path], + projectRoot: '/repo', + configPath: '/repo/.kimi-code/local.toml', + persisted: options.persist, + })), + }; + const host = { + state, + session, + skillCommandMap: new Map<string, string>(), + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(state.appState, patch)), + refreshSlashCommandAutocomplete: vi.fn(), + appendTranscriptEntry: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + sendNormalUserInput: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(() => { + mountedPanel = null; + }), + } as unknown as SlashCommandHost & { + session: typeof session; + state: typeof state; + setAppState: ReturnType<typeof vi.fn>; + refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; + appendTranscriptEntry: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + showStatus: ReturnType<typeof vi.fn>; + sendNormalUserInput: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + }; + return { + host, + session, + getMountedPanel: () => mountedPanel, + }; +} + +describe('handleAddDirCommand', () => { + it('shows the empty message when no additional dirs are configured', async () => { + const { host } = makeHost(); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No additional directories configured.'); + }); + + it('lists current additional dirs for no args', async () => { + const { host } = makeHost(['/repo/shared', '/repo/docs']); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith( + 'Additional directories:\n /repo/shared\n /repo/docs', + ); + }); + + it('lists current additional dirs for the list subcommand', async () => { + const { host } = makeHost(['/repo/shared']); + + await handleAddDirCommand(host, 'list'); + + expect(host.showStatus).toHaveBeenCalledWith('Additional directories:\n /repo/shared'); + }); + + it('renders the add-dir confirmation without option descriptions', async () => { + const { host, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + + const rendered = getMountedPanel()?.render(120).map(strip).join('\n') ?? ''; + expect(rendered).toContain('Add directory to workspace: ../shared'); + expect(rendered).toContain('Yes, for this session'); + expect(rendered).toContain('Yes, and remember this directory'); + expect(rendered).toContain('No'); + expect(rendered).not.toContain('Use this directory in the current session only'); + expect(rendered).not.toContain('Save this directory to the project workspace config'); + expect(rendered).not.toContain('Do not add this directory.'); + }); + + it('adds a workspace dir for this session only after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false }); + }); + expect(host.restoreEditor).toHaveBeenCalledOnce(); + expect(host.setAppState).toHaveBeenCalledWith({ + additionalDirs: ['../shared'], + }); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n For this session only', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('adds a remembered workspace dir after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true }); + }); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + }); + + it('does not add a workspace dir when the confirmation is cancelled', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); + + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Did not add ../shared as a working directory.'); + }); + + it('routes /add-dir errors through the slash-command dispatcher error handler', async () => { + const { host, session, getMountedPanel } = makeHost(); + session.addAdditionalDir.mockRejectedValueOnce(new Error('workspace.additional_dir must exist and be a directory')); + + dispatchInput(host, '/add-dir ../other'); + await vi.waitFor(() => { + expect(getMountedPanel()).not.toBeNull(); + }); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'workspace.additional_dir must exist and be a directory', + ); + }); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 4c8233096..89bf62da6 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -34,7 +34,7 @@ function makeHost() { }; const host = { state: { - theme: { colors: darkColors }, + theme: { palette: darkColors }, ui: { requestRender: vi.fn() }, }, harness: { @@ -110,7 +110,7 @@ describe('experimental feature command handlers', () => { expect(host.harness.setConfig).not.toHaveBeenCalled(); expect(host.showStatus).toHaveBeenCalledWith( 'No experimental feature changes to apply.', - darkColors.textMuted, + 'textMuted', ); }); }); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 3571a94c6..ea59d4fae 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -16,7 +16,7 @@ import { updateGoalQueueItem, } from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; vi.mock('#/tui/goal-queue-store', () => ({ appendGoalQueueItem: vi.fn(async () => ({ @@ -108,7 +108,7 @@ function makeHost( }, transcriptContainer, ui: { requestRender: vi.fn() }, - theme: { colors: getColorPalette('dark') }, + theme: { palette: getBuiltInPalette('dark') }, }, session: hasSession ? session : undefined, skillCommandMap: new Map<string, string>(), @@ -251,7 +251,6 @@ describe('handleGoalCommand', () => { expect(session.createGoal).toHaveBeenCalledWith( expect.objectContaining({ objective: 'Ship feature X', replace: false }), ); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); @@ -331,6 +330,25 @@ describe('handleGoalCommand', () => { expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); }); + it('restores the previous permission mode when the goal fails to start', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + s.createGoal = vi.fn(async () => { + throw new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'A goal already exists'); + }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + // Switched to YOLO to run the goal, then restored to Manual on failure. + expect(s.setPermission).toHaveBeenLastCalledWith('manual'); + }); + expect(s.setPermission).toHaveBeenCalledWith('yolo'); + expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' }); + }); + it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); diff --git a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts new file mode 100644 index 000000000..eae75a099 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPluginSlashCommands, pluginCommandName } from '#/tui/commands/plugin-commands'; + +describe('pluginCommandName', () => { + it('namespaces a command with its plugin id', () => { + expect(pluginCommandName('my-plugin', 'deploy')).toBe('my-plugin:deploy'); + }); +}); + +describe('buildPluginSlashCommands', () => { + it('namespaces commands and maps them to their bodies', () => { + const { commands, commandMap } = buildPluginSlashCommands([ + { + pluginId: 'my-plugin', + name: 'deploy', + description: 'Deploy', + body: 'Deploy $ARGUMENTS', + path: '/p/deploy.md', + }, + ]); + expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); + }); + + it('returns empty commands for no defs', () => { + const { commands, commandMap } = buildPluginSlashCommands([]); + expect(commands).toEqual([]); + expect(commandMap.size).toBe(0); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..bc4c5894f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -3,6 +3,7 @@ import { findBuiltInSlashCommand, parseSlashInput, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, swarmArgumentCompletions, type KimiSlashCommand, @@ -73,6 +74,27 @@ describe('built-in slash command registry', () => { expect(values('Ship feature X')).toBeNull(); }); + it('offers add-dir list and directory argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = addDirArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', @@ -126,6 +148,7 @@ describe('built-in slash command registry', () => { expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ + 'add-dir', 'compact', 'btw', 'editor', diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 317d2bea7..77f582a1b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -8,6 +8,7 @@ import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; +import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { isExperimentalFlagEnabled, @@ -60,7 +61,7 @@ auto_install = false }); expect(host.showStatus).toHaveBeenCalledWith( 'TUI config reloaded.', - host.state.theme.colors.success, + 'success', ); }); @@ -71,7 +72,9 @@ auto_install = false await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( session, 'Session reloaded.', @@ -85,6 +88,31 @@ auto_install = false fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, }); }); + + it('awaits the async theme application before refreshing terminal tracking', async () => { + await writeTuiConfig('theme = "auto"\n'); + const host = makeHost(); + const mutable = host as unknown as { + applyTheme: (theme: string) => Promise<void>; + refreshTerminalThemeTracking: () => void; + state: { appState: { theme: string } }; + }; + + let themeWhenTracked: string | undefined; + // Theme application resolves on a later microtask, mirroring the real + // async palette load; tracking must observe the *new* theme. + mutable.applyTheme = vi.fn(async (theme: string) => { + await Promise.resolve(); + mutable.state.appState.theme = theme; + }); + mutable.refreshTerminalThemeTracking = vi.fn(() => { + themeWhenTracked = mutable.state.appState.theme; + }); + + await handleReloadTuiCommand(host); + + expect(themeWhenTracked).toBe('auto'); + }); }); async function writeTuiConfig(text: string): Promise<void> { @@ -109,9 +137,11 @@ function makeHost({ availableModels: {}, availableProviders: {}, }, + editor: { + setDisablePasteBurst: vi.fn(), + }, theme: { - resolvedTheme: 'dark', - colors: { + palette: { success: '#00ff00', }, }, diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index cf009b158..614553bb4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -14,6 +14,7 @@ function resolve( return resolveSlashCommandInput({ input, skillCommandMap: new Map<string, string>(), + pluginCommandMap: new Map<string, string>(), isStreaming: false, isCompacting: false, ...overrides, @@ -39,6 +40,11 @@ describe('resolveSlashCommandInput', () => { name: 'title', args: 'New title', }); + expect(resolve('/add-dir list')).toMatchObject({ + kind: 'builtin', + name: 'add-dir', + args: 'list', + }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); expect(resolve('/btw')).toMatchObject({ kind: 'builtin', @@ -88,6 +94,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); expect(resolve('/experiments', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -121,6 +132,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); expect(resolve('/experiments', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -211,6 +227,17 @@ describe('resolveSlashCommandInput', () => { }); }); + it('resolves unprefixed sub-skill commands with dotted names', () => { + const skillCommandMap = new Map([['outer.inner', 'outer.inner']]); + + expect(resolve('/outer.inner src/app.ts', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'outer.inner', + skillName: 'outer.inner', + args: 'src/app.ts', + }); + }); + it('returns message for unknown slash input', () => { expect(resolve('/does-not-exist arg')).toEqual({ kind: 'message', @@ -281,4 +308,33 @@ describe('slash command busy helpers', () => { expect(slashBusyMessage('new', 'streaming')).toContain('Cannot /new while streaming'); expect(slashBusyMessage('new', 'compacting')).toContain('Cannot /new while compacting'); }); + + it('resolves a namespaced plugin command to a plugin-command intent', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('/my-plugin:deploy prod', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'prod', + }); + }); + + it('resolves a nested plugin command whose name contains a slash', () => { + const pluginCommandMap = new Map([['my-plugin:frontend/component', 'body']]); + expect(resolve('/my-plugin:frontend/component spin', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'frontend/component', + pluginId: 'my-plugin', + args: 'spin', + }); + }); + + it('blocks a plugin command while streaming', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect(resolve('/my-plugin:deploy', { pluginCommandMap, isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'my-plugin:deploy', + reason: 'streaming', + }); + }); }); diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index cdb5c8a51..b9cf46bd6 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -90,4 +90,16 @@ describe('skill slash commands', () => { expect(built.commands.map((command) => command.name)).toEqual(['mcp-config']); expect(built.commandMap.get('mcp-config')).toBe('mcp-config'); }); + + it('keeps sub-skills slash-invocable', () => { + const built = buildSkillSlashCommands([ + skill('outer.inner', 'prompt', { + isSubSkill: true, + source: 'project', + }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']); + expect(built.commandMap.get('outer.inner')).toBe('outer.inner'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 3c9418989..3e3c9b11a 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { handleSwarmCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { getColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; const ENTER = '\r'; const ESCAPE = '\u001B'; @@ -36,7 +36,7 @@ function makeHost( permissionMode: overrides.permissionMode ?? 'auto', swarmMode: overrides.swarmMode ?? false, }, - theme: { colors: getColorPalette('dark') }, + theme: currentTheme, transcriptContainer: { addChild: vi.fn() }, ui: { requestRender: vi.fn() }, }, @@ -214,7 +214,7 @@ describe('handleSwarmCommand', () => { expect(host.sendNormalUserInput).not.toHaveBeenCalled(); const text = stripAnsi(mountedPicker(host).render(80).join('\n')); expect(text).toContain('Manual mode can block swarm work'); - expect(text).not.toContain('Switch to YOLO and start'); + expect(text).toContain('Switch to YOLO and start'); expect(text).not.toContain('Do not start'); }); @@ -242,6 +242,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); const picker = mountedPicker(host); picker.handleInput(DOWN); + picker.handleInput(DOWN); picker.handleInput(ENTER); await vi.waitFor(() => { @@ -254,6 +255,26 @@ describe('handleSwarmCommand', () => { expectSwarmMarker(host, 'Swarm activated'); }); + it('can start a Manual-mode swarm task after switching to YOLO', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index 910fc4a58..b584c33d6 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -30,7 +30,7 @@ describe('update preference commands', () => { notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, }, - theme: { colors: darkColors }, + theme: { palette: darkColors }, }, setAppState, showStatus, @@ -42,6 +42,7 @@ describe('update preference commands', () => { expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ theme: 'auto', editorCommand: null, + disablePasteBurst: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, }); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts new file mode 100644 index 000000000..cbd6e5eec --- /dev/null +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; + +const mocks = vi.hoisted(() => ({ + ensureDaemon: vi.fn(), + tryResolveServerToken: vi.fn(), + getDataDir: vi.fn(() => '/tmp/kimi-home'), + openUrl: vi.fn(), +})); + +vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/server/daemon')>(); + return { ...actual, ensureDaemon: mocks.ensureDaemon }; +}); + +vi.mock('#/cli/sub/server/shared', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/server/shared')>(); + return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken }; +}); + +vi.mock('#/utils/open-url', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/open-url')>(); + return { ...actual, openUrl: mocks.openUrl }; +}); + +vi.mock('#/utils/paths', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/paths')>(); + return { ...actual, getDataDir: mocks.getDataDir }; +}); + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +function makeHost() { + let mountedPanel: MountedPanel | null = null; + const host = { + session: { id: 'ses-1' }, + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(), + setExitOpenUrl: vi.fn(), + stop: vi.fn(async () => {}), + } as unknown as SlashCommandHost & { + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + setExitOpenUrl: ReturnType<typeof vi.fn>; + stop: ReturnType<typeof vi.fn>; + }; + return { host, getMountedPanel: () => mountedPanel }; +} + +describe('web slash command', () => { + it('is registered as an always-available built-in', () => { + const command = findBuiltInSlashCommand('web'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); +}); + +describe('handleWebCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); + mocks.ensureDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: false, + host: '127.0.0.1', + port: 58627, + }); + }); + + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); + }); + + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); +}); + +describe('webSessionUrl', () => { + it('deep-links to the session under the origin', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); + + it('strips a trailing slash from the origin', () => { + expect(webSessionUrl('http://127.0.0.1:58627/', 'abc123')).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); + + it('encodes session ids so the web UI can decode them', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'a/b c')).toBe( + 'http://127.0.0.1:58627/sessions/a%2Fb%20c', + ); + }); + + it('carries the bearer token in the fragment so the browser authenticates on load', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', 'tok-1')).toBe( + 'http://127.0.0.1:58627/sessions/abc123#token=tok-1', + ); + }); + + it('omits the fragment when no token is available', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', undefined)).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts new file mode 100644 index 000000000..aecf815d9 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -0,0 +1,204 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; + +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +function makeBannerState(overrides: Partial<BannerState> = {}): BannerState { + return { + key: 'component-banner', + tag: null, + mainText: '', + subText: null, + display: 'always', + ...overrides, + }; +} + +const banner: BannerState = makeBannerState({ + tag: "What's new:", + mainText: 'This is the main banner message for testing purposes.', + subText: 'This is a short subtext line.', +}); + +describe('BannerComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders star tag, main text, and subtext', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + expect(lines[0]).toContain('This is the main banner message'); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[2]).toBe(''); + }); + + it('does not add an extra colon to the tag', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).not.toContain("What's new::"); + }); + + it('renders without a tag when tag is empty', () => { + const lines = new BannerComponent(makeBannerState({ mainText: 'Hello' })).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).toContain('Hello'); + expect(lines[1]).toBe(''); + }); + + it('wraps long main text to fit available width', () => { + const width = 30; + const lines = new BannerComponent(banner).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.some((line) => line.includes('…'))).toBe(false); + const mainContentLines = lines.filter((line) => + /main|banner|message|testing|purposes/.test(line), + ); + expect(mainContentLines.length).toBeGreaterThan(1); + }); + + it('wraps long subtext to fit available width', () => { + const width = 30; + const state = makeBannerState({ + mainText: 'Short', + subText: 'Short subtext line one plus subtext line two for wrapping tests.', + }); + const lines = new BannerComponent(state).render(width); + expect(lines[0]).toContain('Short'); + const subContentLines = lines.filter((line) => + /Short subtext|line one|plus|subtext|line two|for|wrapping|tests/.test(line), + ); + expect(subContentLines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('keeps every line within terminal width on very narrow terminals', () => { + for (const width of [0, 1, 2, 3, 5, 10]) { + const lines = new BannerComponent(banner).render(width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); + + it('shows tag only on the first wrapped line', () => { + const width = 40; + const state = makeBannerState({ + tag: 'New:', + mainText: 'This is a very long main text line that should wrap automatically.', + }); + const lines = new BannerComponent(state).render(width); + const mainRows = lines.slice(0, -1); + let tagCount = 0; + for (const line of mainRows) { + if (line.includes('✦ New:')) tagCount += 1; + } + expect(tagCount).toBe(1); + expect(mainRows.length).toBeGreaterThan(1); + const firstIndex = lines.findIndex((line) => line.includes('✦ New:')); + expect(firstIndex).toBe(0); + }); + + it('continues main text under the tag column and keeps subtext aligned with the tag text', () => { + const width = 80; + const lines = new BannerComponent(banner).render(width); + const firstLine = lines[0]!; + const mainStartIndex = firstLine.indexOf('This is the main banner message'); + const tagPrefixVisibleWidth = visibleWidth(firstLine.slice(0, mainStartIndex)); + const subLine = lines[1]!; + const subStartIndex = subLine.indexOf('This is a short subtext'); + const subIndentVisibleWidth = visibleWidth(subLine.slice(0, subStartIndex)); + // The subtext starts two columns after the left edge ("✦ "), which aligns + // with the tag text itself rather than the main-text column. + expect(subIndentVisibleWidth).toBe(visibleWidth('✦ ')); + expect(tagPrefixVisibleWidth).toBeGreaterThan(visibleWidth('✦ ')); + }); + + it('drops the tag when it does not fit', () => { + const width = 5; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).not.toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('still renders the tag when it fits', () => { + const width = 40; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('does not render subtext when empty', () => { + const lines = new BannerComponent(makeBannerState({ tag: 'Tip', mainText: 'Use /help' })).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).toContain('Use /help'); + expect(lines[1]).toBe(''); + }); + + it('supports explicit newlines in main text', () => { + const lines = new BannerComponent(makeBannerState({ mainText: 'Line 1\nLine 2' })).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('Line 1'); + expect(lines[1]).toContain('Line 2'); + expect(lines[2]).toBe(''); + }); + + it('styles tag, main text, and subtext with theme colors', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).toContain(currentTheme.boldFg('primary', "✦ What's new:")); + expect(lines[0]).toContain( + currentTheme.boldFg('textStrong', 'This is the main banner message for testing purposes.'), + ); + expect(lines[1]).toContain(currentTheme.fg('textDim', 'This is a short subtext line.')); + }); + + it('does not stack the dim modifier on top of the textDim color', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[1]).not.toContain(''); + expect(lines[2]).toBe(''); + }); + + it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { + const width = 20; + const lines = new BannerComponent( + makeBannerState({ + tag: 'New:', + mainText: 'Line 1 with a lot of content', + subText: 'Sub text', + }), + ).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines[0]).toContain('✦ New:'); + const firstLine = lines[0]!; + const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); + const continuationLine = lines.find((line) => line.includes('lot of'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const subLine = lines.find((line) => line.includes('Sub text'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index f7cea9b92..c0d4e929f 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; @@ -19,7 +20,6 @@ describe('DeviceCodeBoxComponent', () => { url, code, hint, - colors: darkColors, }); const lines = component.render(80).map(strip); @@ -42,7 +42,6 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const lines = component.render(40).map(strip); @@ -57,10 +56,24 @@ describe('DeviceCodeBoxComponent', () => { title, url, code, - colors: darkColors, }); const joined = component.render(80).map(strip).join('\n'); expect(joined).not.toContain('Press Ctrl-C'); }); + + it('keeps every line within narrow widths', () => { + const component = new DeviceCodeBoxComponent({ + title, + url, + code, + hint, + }); + + for (const width of [39, 20, 10, 4]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index e5d56b0ed..2fe6f3e52 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -34,11 +35,12 @@ function setDanceView(colored: boolean, phase: number): void { const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -47,6 +49,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, @@ -71,7 +74,7 @@ describe('FooterComponent', () => { it('paints the model name in rainbow while colored', () => { setDanceView(true, 0); - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -82,11 +85,105 @@ describe('FooterComponent', () => { }); it('renders the model name in its normal color when not dancing', () => { - const footer = new FooterComponent(appState, darkColors); + const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); expect(codes.has(RAINBOW_CYAN)).toBe(false); expect(codes.has(RAINBOW_GREEN)).toBe(false); }); + + it('repaints from the active palette on the next render (no setColors needed)', () => { + const footer = new FooterComponent(appState); + const before = footer.render(120).join('\n'); + + currentTheme.setPalette(lightColors); + try { + const after = footer.render(120).join('\n'); + // Reads currentTheme live, so a palette swap changes the emitted colours. + expect(after).not.toBe(before); + } finally { + currentTheme.setPalette(darkColors); + } + }); + + it('shows the effort for an effort-capable model', () => { + const effortModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }; + const state: AppState = { + ...appState, + thinkingEffort: 'max', + availableModels: { 'kimi-k2': effortModel }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('thinking: max'); + }); + + it('does not show the effort for a legacy boolean model', () => { + const plainModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: ['thinking'], + }; + const state: AppState = { + ...appState, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': plainModel }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(120).join('\n'); + + expect(rendered).toContain('thinking'); + expect(rendered).not.toContain('thinking:high'); + }); +}); + +describe('FooterComponent overrides', () => { + it('shows the overridden effort list', () => { + const effortModelWithOverride: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + overrides: { supportEfforts: ['low', 'high'], defaultEffort: 'high' }, + }; + const state: AppState = { + ...appState, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': effortModelWithOverride }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('thinking: high'); + }); +}); + +describe('FooterComponent displayName override', () => { + it('renders the overridden display name', () => { + const state: AppState = { + ...appState, + model: 'kimi-k2', + availableModels: { + 'kimi-k2': { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Remote Name', + overrides: { displayName: 'Custom Name' }, + }, + }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('Custom Name'); + expect(footer.render(120).join('\n')).not.toContain('Remote Name'); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index c26e97a17..295363a74 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { GutterContainer } from '#/tui/components/chrome/gutter-container'; diff --git a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts new file mode 100644 index 000000000..ffdc5bcbe --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -0,0 +1,42 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; + +// MoonLoader starts a real setInterval in its constructor, so every loader +// created in these tests must be stopped to avoid leaving live timers behind. +const loaders: MoonLoader[] = []; + +function createLoader(): MoonLoader { + const ui = { requestRender() {} } as unknown as TUI; + const loader = new MoonLoader(ui, 'moon'); + loaders.push(loader); + return loader; +} + +afterEach(() => { + for (const loader of loaders) loader.stop(); + loaders.length = 0; +}); + +describe('MoonLoader', () => { + it('keeps the tip out of renderInline so it does not squeeze against the swarm progress bar', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const inline = loader.renderInline(); + expect(inline).not.toContain('Tip'); + expect(inline).not.toContain('steer'); + expect(inline.trim().length).toBeGreaterThan(0); + }); + + it('still shows the tip on its own row when width allows', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const row = loader.render(80).join('\n'); + expect(row).toContain('Tip: ctrl+s: steer mid-turn'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 530f9281e..bc1b754fb 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -11,11 +12,12 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -24,6 +26,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + inputMode: 'prompt', swarmMode: false, theme: 'dark', editorCommand: null, @@ -71,7 +74,7 @@ describe('WelcomeComponent', () => { }); it('renders the banner in a single brand color by default', () => { - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); // No rainbow by default — just the brand primary (plus the dim tagline). expect(codes.size).toBeLessThanOrEqual(2); @@ -79,16 +82,24 @@ describe('WelcomeComponent', () => { it('paints the banner in rainbow while colored', () => { setDanceView(true, 0); - const codes = truecolorCodes(headerOf(new WelcomeComponent(appState, darkColors).render(80))); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); }); it('renders exactly the default banner when not colored', () => { - const base = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const base = headerOf(new WelcomeComponent(appState).render(80)); setDanceView(false, 5); - const off = headerOf(new WelcomeComponent(appState, darkColors).render(80)); + const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); }); + + it('keeps every line within the requested width on narrow terminals', () => { + for (const width of [0, 1, 2, 4, 10, 39, 80]) { + for (const line of new WelcomeComponent(appState).render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts new file mode 100644 index 000000000..610ea23e2 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -0,0 +1,21 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; + +describe('ApiKeyInputDialogComponent', () => { + it('keeps every line within narrow widths', () => { + const dialog = new ApiKeyInputDialogComponent( + 'Kimi Code', + ['Paste your API key below.', 'It will be stored locally.'], + () => {}, + ); + dialog.focused = true; + + for (const width of [39, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 9454ab316..d02df500e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -1,4 +1,4 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; @@ -7,12 +7,9 @@ import type { FileContentDisplayBlock, PendingApproval, } from '#/tui/reverse-rpc/types'; -import { getColorPalette } from '#/tui/theme/colors'; import { captureProcessWrite } from '../../../helpers/process'; -const COLORS = getColorPalette('dark'); - function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -52,7 +49,6 @@ function makeDialog(): { const dialog = new ApprovalPanelComponent( makePending(), (response) => responses.push(response), - COLORS, ); return { dialog, responses }; } @@ -65,6 +61,33 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('y/a/n/f'); }); + it('renders choice descriptions beneath the label when present', () => { + const pending: PendingApproval = { + data: { + id: 'approval_goal', + tool_call_id: 'tool_goal', + tool_name: 'CreateGoal', + action: 'Creating a goal', + description: '', + display: [], + choices: [ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: 'Tools are approved automatically, and questions are skipped.', + }, + { label: 'Do not start', response: 'cancelled', selected_label: 'cancel' }, + ], + }, + }; + const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); + expect(out).toContain('1. Switch to Auto and start'); + expect(out).toContain('Tools are approved automatically, and questions are skipped.'); + // A choice without a description stays label-only — no stray blank helper line. + expect(out).toContain('2. Do not start'); + }); + it('renders dangerous shell warnings with simple copy and no icon', () => { const pending: PendingApproval = { data: { @@ -84,7 +107,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Dangerous: recursive delete'); @@ -113,7 +136,7 @@ describe('ApprovalPanelComponent', () => { choices: [{ label: 'Approve once', response: 'approved' }], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const rendered = dialog.render(60); const out = strip(rendered.join('\n')); @@ -216,7 +239,7 @@ describe('ApprovalPanelComponent', () => { ], }, }; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const dialog = new ApprovalPanelComponent(pending, () => {}); const out = strip(dialog.render(80).join('\n')); expect(out).toContain('Ready to build with this plan?'); @@ -264,7 +287,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, () => toolOutputToggles++, (block) => previewCalls.push(block), ); @@ -307,7 +329,7 @@ describe('ApprovalPanelComponent', () => { }, }; let globalToggleCalls = 0; - const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); + const dialog = new ApprovalPanelComponent(pending, () => {}, () => globalToggleCalls++); dialog.handleInput('\u000F'); // Ctrl+O @@ -333,7 +355,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -366,7 +387,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (r) => responses.push(r), - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -408,7 +428,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, () => {}, - COLORS, undefined, (block) => previewCalls.push(block), ); @@ -451,7 +470,6 @@ describe('ApprovalPanelComponent', () => { const dialog = new ApprovalPanelComponent( pending, (response) => responses.push(response), - COLORS, ); dialog.handleInput('2'); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index e3f216a1e..316b5f89d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,14 +1,10 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from '#/tui/components/dialogs/approval-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); - const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); @@ -49,7 +45,6 @@ function makeViewer(opts: { return new ApprovalPreviewViewer( { block: opts.block, - colors: COLORS, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 24, opts.columns ?? 100), @@ -143,6 +138,26 @@ describe('ApprovalPreviewViewer', () => { expect(text).toContain('BETA'); }); + it('shows surrounding context lines for a diff block', () => { + const viewer = makeViewer({ + block: { + type: 'diff', + path: 'src/foo.ts', + old_text: ['before1', 'before2', 'old', 'after1', 'after2'].join('\n'), + new_text: ['before1', 'before2', 'new', 'after1', 'after2'].join('\n'), + }, + rows: 24, + }); + + const text = strip(viewer.render(100).join('\n')); + expect(text).toContain('before1'); + expect(text).toContain('before2'); + expect(text).toContain('old'); + expect(text).toContain('new'); + expect(text).toContain('after1'); + expect(text).toContain('after2'); + }); + // Sanity: rendering is a pure slice — repeated render() calls without // input changes produce the same output, no incremental state drift. it('renders deterministically across repeated calls', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index bc119edb4..ec590e252 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -22,7 +23,6 @@ describe('ChoicePickerComponent', () => { { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, ], - colors: darkColors, searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -61,7 +61,6 @@ describe('ChoicePickerComponent', () => { }, ], currentValue: 'manual', - colors: darkColors, onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -79,7 +78,6 @@ describe('ChoicePickerComponent', () => { const editor = new EditorSelectorComponent({ currentValue: 'vim', - colors: darkColors, onSelect, onCancel, }); @@ -87,7 +85,6 @@ describe('ChoicePickerComponent', () => { const theme = new ThemeSelectorComponent({ currentValue: 'light', - colors: darkColors, onSelect, onCancel, }); @@ -95,14 +92,12 @@ describe('ChoicePickerComponent', () => { const permission = new PermissionSelectorComponent({ currentValue: 'manual', - colors: darkColors, onSelect, onCancel, }); expect(permission.render(120).map(strip)).toContain(' ❯ Manual ← current'); const settings = new SettingsSelectorComponent({ - colors: darkColors, onSelect, onCancel, }); @@ -113,7 +108,6 @@ describe('ChoicePickerComponent', () => { const upgradePreference = new UpdatePreferenceSelectorComponent({ currentValue: true, - colors: darkColors, onSelect, onCancel, }); @@ -131,7 +125,6 @@ describe('ChoicePickerComponent', () => { { value: 'azure', label: 'Azure OpenAI' }, ], searchable: true, - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -145,7 +138,6 @@ describe('ChoicePickerComponent', () => { const picker = new ChoicePickerComponent({ title: 'Pick one', options: [{ value: 'a', label: 'Alpha' }], - colors: darkColors, onSelect, onCancel: vi.fn(), }); @@ -153,4 +145,35 @@ describe('ChoicePickerComponent', () => { picker.handleInput(' '); expect(onSelect).toHaveBeenCalledWith('a'); }); + + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', + }, + ]; + + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; + + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index b49501978..4f415bc32 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; +import { afterEach, describe, expect, it } from 'vitest'; import { CompactionComponent } from '#/tui/components/dialogs/compaction'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -9,7 +14,7 @@ function strip(text: string): string { describe('CompactionComponent', () => { it('renders the custom instruction below the compacting label', () => { - const component = new CompactionComponent(darkColors, undefined, 'keep the recent files only'); + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { const lines = component.render(120).map(strip); @@ -22,8 +27,37 @@ describe('CompactionComponent', () => { } }); + it('renders a tip suffix while compacting', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); + } finally { + component.dispose(); + } + }); + + it('does not render a tip after compaction completes', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + component.markDone(1000, 500); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).not.toContain('Tip:'); + expect(text).not.toContain('Ctrl-O'); + } finally { + component.dispose(); + } + }); + it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(darkColors); + const component = new CompactionComponent(); try { component.markCanceled(); @@ -36,4 +70,109 @@ describe('CompactionComponent', () => { component.dispose(); } }); + + it('keeps the completed compaction summary hidden until expanded', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + const collapsed = component.render(120).map(strip).join('\n'); + + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('Ctrl-O to show compaction summary'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + component.setExpanded(true); + const expanded = component.render(120).map(strip).join('\n'); + + expect(expanded).toContain('Compaction complete'); + expect(expanded).toContain('Ctrl-O to hide compaction summary'); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('hides the compaction summary again when collapsed', () => { + const component = new CompactionComponent(); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.setExpanded(false); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).toContain('Ctrl-O to show compaction summary'); + expect(text).not.toContain('Ctrl-O to hide compaction summary'); + expect(text).not.toContain('Keep the src/tui compaction notes.'); + } finally { + component.dispose(); + } + }); + + it('preserves the expanded summary when invalidating with an instruction', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('keep the recent files only'); + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.match(/keep the recent files only/g)).toHaveLength(1); + } finally { + component.dispose(); + } + }); + + it('keeps expanded summary child order on invalidate', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); + + try { + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + currentTheme.setPalette(lightColors); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.indexOf('keep the recent files only')).toBeLessThan( + text.indexOf('Keep the src/tui compaction notes.'), + ); + } finally { + component.dispose(); + } + }); + + it('repaints the header with the active palette on invalidate', () => { + // Force truecolor so palette differences surface as ANSI codes even when + // the test runner has no TTY. + const previousLevel = chalk.level; + chalk.level = 3; + const component = new CompactionComponent(); + + try { + const headerOf = (): string => { + const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); + if (line === undefined) throw new Error('header line not found'); + return line; + }; + const before = headerOf(); + + currentTheme.setPalette(lightColors); + component.invalidate(); + const after = headerOf(); + + // Same visible text, different ANSI colour codes. + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + component.dispose(); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index 66bc3badd..68c79974d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { @@ -23,7 +24,6 @@ function makeDialog(defaultUrl = 'https://example.com/api.json'): { const onDone = vi.fn(); const dialog = new CustomRegistryImportDialogComponent( onDone as unknown as (r: CustomRegistryImportResult) => void, - darkColors, defaultUrl, ); dialog.focused = true; @@ -68,4 +68,14 @@ describe('CustomRegistryImportDialogComponent', () => { value: { url: 'https://example.com/api.json', apiKey: 'sk-tok' }, }); }); + + it('keeps every line within narrow widths', () => { + const { dialog } = makeDialog('https://example.com/very/long/registry/path.json'); + + for (const width of [39, 35, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts new file mode 100644 index 000000000..e74fa7aa1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; + +function text(component: EffortSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('EffortSelectorComponent', () => { + it('renders efforts as horizontal segments with the active one bracketed', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = text(picker); + // All efforts are rendered on a single row. + expect(out).toContain('Off'); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).toContain('Max'); + // The active level is wrapped in brackets; the rest are not. + expect(out).toContain('[ High ]'); + expect(out).not.toContain('[ Off ]'); + expect(out).not.toContain('[ Max ]'); + }); + + it('invokes onSelect with the chosen effort on Enter', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('high'); + }); + + it('moves the active segment with Left/Right and stops at the edges', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // index 2 (high) -> 3 (max). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Already at the right edge — another Right stays put. + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Walk back to the left edge (max -> high -> low -> off). + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + + // Already at the left edge — another Left stays put. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + }); + + it('invokes onSessionOnlySelect on Alt+S instead of onSelect', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith('high'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('cancels on Escape', () => { + const onCancel = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel, + }); + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts index ce402d3dd..f80c3a3da 100644 --- a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -5,7 +5,7 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '#/tui/components/dialogs/experiments-selector'; -import { darkColors } from '#/tui/theme/colors'; + const ANSI = /\u001B\[[0-9;]*m/g; const ESC = String.fromCodePoint(27); @@ -41,7 +41,6 @@ describe('ExperimentsSelectorComponent', () => { features: [ feature({ enabled: true, source: 'config', configValue: true }), ], - colors: darkColors, onApply: vi.fn(), onCancel: vi.fn(), }); @@ -61,7 +60,6 @@ describe('ExperimentsSelectorComponent', () => { const first = feature(); const selector = new ExperimentsSelectorComponent({ features: [first], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -91,7 +89,6 @@ describe('ExperimentsSelectorComponent', () => { source: 'env', }), ], - colors: darkColors, onApply, onCancel: vi.fn(), }); @@ -108,7 +105,6 @@ describe('ExperimentsSelectorComponent', () => { const onCancel = vi.fn(); const selector = new ExperimentsSelectorComponent({ features: [feature()], - colors: darkColors, onApply: vi.fn(), onCancel, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 13fcdd589..7ab1b3312 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -1,3 +1,4 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -26,7 +27,7 @@ function makeDialog(): { const collected: FeedbackInputDialogResult[] = []; const dialog = new FeedbackInputDialogComponent((result) => { collected.push(result); - }, darkColors); + }); dialog.focused = true; return { dialog, collected }; } @@ -55,6 +56,16 @@ describe('FeedbackInputDialogComponent', () => { expect(rendered).toContain(`${ansiOpen}╰`); }); + it('keeps every line within narrow widths', () => { + const { dialog } = makeDialog(); + + for (const width of [39, 20, 10, 4]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('typing then pressing Enter submits the trimmed value', () => { const { dialog, collected } = makeDialog(); for (const ch of 'hello world ') { diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 23062ea2b..ec0f9d03b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { @@ -6,7 +6,6 @@ import { GoalQueueManagerComponent, type GoalQueueManagerAction, } from '#/tui/components/dialogs/goal-queue-manager'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; const ANSI = /\u001B\[[0-9;]*m/g; @@ -39,7 +38,6 @@ describe('GoalQueueManagerComponent', () => { it('renders the upcoming goals and the management hint', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'Ship queued goal')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -59,7 +57,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -85,7 +82,6 @@ describe('GoalQueueManagerComponent', () => { }); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -112,7 +108,6 @@ describe('GoalQueueManagerComponent', () => { ); const manager = new GoalQueueManagerComponent({ goals: [first, second], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -130,7 +125,6 @@ describe('GoalQueueManagerComponent', () => { const onAction = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First queued goal')], - colors: darkColors, onAction, onCancel: vi.fn(), }); @@ -144,7 +138,6 @@ describe('GoalQueueManagerComponent', () => { const onCancel = vi.fn(); const manager = new GoalQueueManagerComponent({ goals: [], - colors: darkColors, onAction: vi.fn(), onCancel, }); @@ -157,7 +150,6 @@ describe('GoalQueueManagerComponent', () => { it('never renders a line wider than the terminal', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'A very long queued goal objective that should be truncated cleanly')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -172,7 +164,6 @@ describe('GoalQueueManagerComponent', () => { it('renders multiline objectives as a single selectable row', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'First line\nSecond line')], - colors: darkColors, onAction: vi.fn(), onCancel: vi.fn(), }); @@ -189,7 +180,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -207,7 +197,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -226,7 +215,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -245,7 +233,6 @@ describe('GoalQueueEditDialogComponent', () => { it('renders multiline edits inside the dialog width', () => { const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'First line\nSecond line'), - colors: darkColors, onDone: vi.fn(), }); @@ -258,11 +245,23 @@ describe('GoalQueueEditDialogComponent', () => { } }); + it('keeps the edit dialog within narrow widths', () => { + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'A very long queued objective for width testing'), + onDone: vi.fn(), + }); + + for (const width of [24, 20, 10]) { + for (const line of dialog.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('keeps accepting input after save returns control to the mounted dialog', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', 'Ship queued goal'), - colors: darkColors, onDone, }); @@ -276,7 +275,6 @@ describe('GoalQueueEditDialogComponent', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ goal: goal('g1', ''), - colors: darkColors, onDone, }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 0a59030f6..ba1499e04 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -1,8 +1,9 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI = /\[[0-9;]*m/g; @@ -23,6 +24,23 @@ function model(displayName: string, capabilities: string[] = ['thinking']): Mode } as unknown as ModelAlias; } +function effortModel( + displayName: string, + supportEfforts: string[], + defaultEffort?: string, + capabilities: string[] = ['thinking'], +): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + supportEfforts, + defaultEffort, + } as unknown as ModelAlias; +} + function text(component: ModelSelectorComponent, width = 120): string { return component.render(width).map(strip).join('\n'); } @@ -32,8 +50,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, currentValue: 'kimi', - currentThinking: true, - colors: darkColors, + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -50,8 +67,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: true, - colors: darkColors, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); @@ -59,25 +75,24 @@ describe('ModelSelectorComponent', () => { // "/" no longer toggles thinking (it used to); here it is simply ignored. picker.handleInput('/'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); // Right arrow flips the draft (true -> false). picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); // Left arrow flips it back. picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); }); it('shows the Left/Right thinking hint only for toggleable models', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -92,20 +107,63 @@ describe('ModelSelectorComponent', () => { plain: model('Kimi Plain', ['tool_use']), }, currentValue: 'always', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); - expect(text(picker)).toContain('[ Always on ]'); + // Always-on: On selected, Off greyed out with an explanation. + const alwaysOut = text(picker); + expect(alwaysOut).toContain('[ On ]'); + expect(alwaysOut).toContain('Off (Unsupported)'); + expect(alwaysOut).not.toContain('Always on'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); + + // Unsupported: Off selected, On greyed out — same style, mirrored. + picker.handleInput(DOWN); + const plainOut = text(picker); + expect(plainOut).toContain('On (Unsupported)'); + expect(plainOut).toContain('[ Off ]'); + expect(plainOut).not.toContain('] unsupported'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); + }); + + it('ignores Left/Right on always-on and unsupported models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: model('Kimi Thinking', ['always_thinking']), + plain: model('Kimi Plain', ['tool_use']), + }, + currentValue: 'always', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); picker.handleInput(DOWN); - expect(text(picker)).toContain('[ Off ] unsupported'); + picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); + }); + + it('renders the unavailable thinking segment muted', () => { + const picker = new ModelSelectorComponent({ + models: { always: model('Kimi Thinking', ['always_thinking']) }, + currentValue: 'always', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); }); it('keeps the thinking draft when moving across models', () => { @@ -116,8 +174,7 @@ describe('ModelSelectorComponent', () => { thinking: model('Kimi Thinking', ['thinking']), }, currentValue: 'plain', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -128,7 +185,7 @@ describe('ModelSelectorComponent', () => { picker.handleInput(DOWN); // -> thinking (the Off override persists) picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: false }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: 'off' }); }); it('defaults a thinking-capable model to On but keeps the current model state', () => { @@ -139,8 +196,7 @@ describe('ModelSelectorComponent', () => { other: model('Kimi Other', ['thinking']), }, currentValue: 'current', - currentThinking: false, // thinking deliberately off on the active model - colors: darkColors, + currentThinkingEffort: 'off', // thinking deliberately off on the active model onSelect, onCancel: vi.fn(), }); @@ -151,7 +207,7 @@ describe('ModelSelectorComponent', () => { // A capable, non-active model defaults to On without any toggle. expect(text(picker)).toContain('[ On ]'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'on' }); }); it('fuzzy-filters by typing and reports a match count', () => { @@ -159,8 +215,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, currentValue: 'k2', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel, @@ -187,8 +242,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models, currentValue: 'm0', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -205,8 +259,7 @@ describe('ModelSelectorComponent', () => { cjk: model('超长的中文模型名称需要被正确截断处理'), }, currentValue: 'long', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -218,4 +271,176 @@ describe('ModelSelectorComponent', () => { } } }); + + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: 'off' }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); + + it('renders effort segments with the default effort highlighted', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // The default effort (high) is the active segment. + expect(out).toContain('[ High ]'); + // All declared efforts plus the Off entry are present. + expect(out).toContain('Low'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + // Multi-segment control advertises the switch hint. + expect(out).toContain('Thinking (←→ to switch)'); + }); + + it('cycles efforts with Left/Right and clamps at the ends', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // high -> max (Right), then clamp on a second Right. + picker.handleInput(RIGHT); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + + // max -> high -> low -> off (Left x3), then clamp on another Left. + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); + }); + + it('always-on effort models hide Off and clamp selection at the last effort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high', ['always_thinking']), + }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + // Off is not surfaced at all — the selectable segments are effort-only. + expect(raw).not.toContain('Off (Unsupported)'); + // The active effort is still highlighted. + expect(strip(raw)).toContain('[ High ]'); + + // Cycling clamps at the last effort and never reaches Off. + picker.handleInput(RIGHT); // high -> max + picker.handleInput(RIGHT); // clamp at max + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + }); + + it('defaults an effort model without a current level to its defaultEffort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'high', 'max'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // Non-current effort model falls back to its declared defaultEffort. + expect(text(picker)).toContain('[ Max ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'max' }); + }); + + it('falls back to the middle effort when an effort model has no defaultEffort', () => { + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'medium', 'high']), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // support_efforts present but default_effort absent -> default to the + // middle entry (medium), not a hardcoded level. + expect(text(picker)).toContain('[ Medium ]'); + }); +}); + +describe('ModelSelectorComponent overrides', () => { + it('uses overridden support_efforts for selectable efforts', () => { + const picker = new ModelSelectorComponent({ + models: { + kimi: { + ...effortModel('Kimi K2', ['low', 'high', 'max'], 'max'), + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + currentValue: 'kimi', + currentThinkingEffort: 'max', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).not.toContain('Max'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 5a033a2cf..5bd80da5b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -2,21 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, + type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; -import { darkColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; +import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -const ANSI_SGR = /\[[0-9;]*m/g; -const MID = '\u00B7'; -const ESC = String.fromCodePoint(27); -const RIGHT = `${ESC}[C`; -const LEFT = `${ESC}[D`; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -40,6 +39,57 @@ function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); +} + +const superpowers = { + id: 'superpowers', + displayName: 'Superpowers', + version: '5.1.0', + enabled: true, + state: 'ok' as const, + skillCount: 14, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path' as const, +}; + +const officialEntries = [ + { id: 'kimi-datasource', tier: 'official' as const, displayName: 'Kimi Datasource', version: '3.1.1', source: 'https://x/d.zip' }, +]; +const thirdPartyEntries = [ + { id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' }, +]; +const marketplaceEntries = [...officialEntries, ...thirdPartyEntries]; + +function makePanel(opts: { + installed?: readonly (typeof superpowers)[]; + initialTab?: 'installed' | 'official' | 'third-party' | 'custom'; + selectedId?: string; + pluginHint?: { id: string; text: string }; +}) { + const installed = opts.installed ?? []; + const onSelect = vi.fn<(s: PluginsPanelSelection) => void>(); + const onRequestMarketplace = vi.fn(); + const panel = new PluginsPanelComponent({ + installed, + installedIds: new Set(installed.map((p) => p.id)), + initialTab: opts.initialTab, + selectedId: opts.selectedId, + pluginHint: opts.pluginHint, + onSelect, + onCancel: vi.fn(), + onRequestMarketplace, + }); + return { panel, onSelect, onRequestMarketplace }; +} + describe('plugins selector dialogs', () => { it('trusts only built-in Kimi CDN plugin paths', () => { expect(pluginTrustLabel({ @@ -50,6 +100,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', @@ -62,6 +114,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', @@ -74,6 +128,8 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/demo.zip', @@ -86,286 +142,359 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', })).toBe('third-party'); }); - it('renders installed plugins as selectable overview entries', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Installed plugins (1)'); - expect(out).toContain('Actions'); - expect(out).toContain('? Kimi Datasource enabled'); - expect(out).toContain(`id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`); - expect(out).not.toContain('Space disable'); - expect(out).not.toContain('Enter info'); - expect(out).toContain('Space toggle · M MCP servers · D remove · Enter details'); - expect(out).toContain('Marketplace'); - expect(out).toContain('Summary'); - - picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' }); + it('treats only the official Kimi CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + // Curated and other Kimi CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); + // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); }); - it('ignores Left/Right arrows in the overview (no enter/exit by arrow)', () => { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel, - }); - - picker.handleInput(RIGHT); // must NOT open details - expect(onSelect).not.toHaveBeenCalled(); - picker.handleInput(LEFT); // must NOT cancel/exit - expect(onCancel).not.toHaveBeenCalled(); + it('opens on the Installed tab with the four panel tabs', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Plugins'); + expect(out).toContain('Installed'); + expect(out).toContain('Official'); + expect(out).toContain('Third-party'); + expect(out).toContain('Custom'); + expect(out).toContain('? Superpowers enabled'); + expect(out).toContain('Space toggle'); + expect(out).toContain('1 installed'); }); - it('renders marketplace plugins separately from marketplace actions', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installedIds: new Set(), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); + it('repaints from the current theme palette without remounting', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkOut = renderRaw(panel); + currentTheme.setPalette(lightColors); + const lightOut = renderRaw(panel); + // A palette snapshot cached at construction would render identically + // after the switch; reading currentTheme.palette at render time must + // produce different ANSI output for the same panel instance. + expect(darkOut).not.toBe(lightOut); + } finally { + currentTheme.setPalette(previous); + } + }); - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Marketplace (1)'); - expect(out).toContain('? Superpowers install v5.1.0'); - expect(out).toContain( - `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, - ); - expect(out).toContain('Enter install/update'); - expect(out).toContain('Actions'); - expect(out).toContain('Back to installed plugins'); + it('toggles an installed plugin with Space', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false }); + }); - picker.handleInput('\r'); + it('routes D / M / R / Enter to remove / mcp / reload / details on the Installed tab', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput('d'); + panel.handleInput('m'); + panel.handleInput('r'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'reload' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('installs only on Enter, not Space, in the marketplace', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installedIds: new Set(), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); - picker.handleInput(' '); // Space must NOT install - expect(onSelect).not.toHaveBeenCalled(); - picker.handleInput('\r'); // Enter installs + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('renders the inline plugin hint on the installed row', () => { + const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; + const { panel } = makePanel({ + installed: [datasource], + selectedId: 'kimi-datasource', + pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, + }); + const out = strip(renderRaw(panel)); + expect(out).toContain('? Kimi Datasource enabled pending /new'); + }); + + it('lazily loads the Official catalog, then lists installed entries first', () => { + const { panel, onRequestMarketplace } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + expect(onRequestMarketplace).toHaveBeenCalledTimes(1); + expect(strip(renderRaw(panel))).toContain('Loading marketplace'); + + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi Datasource install'); + expect(out).toContain('0 installed · 1 available'); + }); + + it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => { + const { panel } = makePanel({ initialTab: 'official' }); + // The catalog is still loading, but the built-in Web Bridge entry is shown + // immediately because it is baked into the TUI, not fetched. + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Loading marketplace'); + }); + + it('keeps the Web Bridge entry visible when the Official catalog errors', () => { + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge open in browser'); + expect(out).toContain('Marketplace unavailable: fetch failed'); + }); + + it('opens the Web Bridge webpage on Enter instead of installing', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + // Web Bridge is pinned at index 0, so Enter selects it directly. + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'open-url', + url: 'https://www.kimi.com/features/webbridge', + label: 'Kimi WebBridge', + }); + }); + + it('installs a catalog official entry after navigating past Web Bridge', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\u001B[B'); // ↓ → kimi-datasource + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-datasource' }), + }); + }); + + it('does not duplicate Web Bridge when the catalog also lists it', () => { + const entries = [ + { + id: 'kimi-webbridge', + tier: 'official' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ...officialEntries, + ]; + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + // The label should appear exactly once — the hardcoded row wins, the + // catalog copy is filtered out. + expect(out.split('Kimi WebBridge').length - 1).toBe(1); + }); + + it('installs a Third-party entry whose id matches the pinned WebBridge', () => { + // A curated/custom marketplace entry can legitimately reuse the + // kimi-webbridge id; on the Third-party tab it must install normally, not + // open the WebBridge page (that shortcut is reserved for the pinned row). + const entries = [ + { + id: 'kimi-webbridge', + tier: 'curated' as const, + displayName: 'Kimi WebBridge', + source: 'https://x/w.zip', + }, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Kimi WebBridge install'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }), + }); + }); + + it('installs the selected Third-party entry on Enter', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('ignores the Left arrow in the marketplace view (Esc returns instead)', () => { - const onCancel = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills', - source: 'https://example.com/superpowers.zip', - keywords: ['workflow'], - }, - ], - installedIds: new Set(), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect: vi.fn(), - onCancel, - }); - - picker.handleInput(LEFT); // must NOT return to the overview - expect(onCancel).not.toHaveBeenCalled(); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); }); - it('issues install for installed marketplace entries (update path)', () => { - const onSelect = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - entries: [ - { - id: 'superpowers', - displayName: 'Superpowers', - source: 'https://example.com/superpowers.zip', - }, - ], - installedIds: new Set(['superpowers']), - source: '/tmp/marketplace.json', - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - expect(out).toContain('? Superpowers installed'); - expect(out).toContain(`Plugin ${MID} id superpowers`); - - picker.handleInput('\r'); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + // Catalog still loading (entries empty); pressing ↓ must not drive the + // selection negative, or the later Enter would read entries[-1]. + panel.handleInput('\u001B[B'); // ↓ + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('toggles an installed plugin from the overview with space', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); + it('shows untiered marketplace entries on the Third-party tab', () => { + const untiered = [ + { id: 'custom-plugin', displayName: 'Custom Plugin', source: 'https://x/c.zip' }, + ]; + const { panel } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(untiered, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Custom Plugin install'); + }); - picker.handleInput(' '); + it('shows an update badge when the marketplace version is newer than installed', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); + }); + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + + it('shows installed · v<version> when the installed plugin is up to date', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers installed · v5.0.0'); + }); + + it('shows an inline error when the Official catalog fails', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Marketplace unavailable: fetch failed'); + expect(out).toContain('Use the Custom tab'); + }); + + it('installs from a URL typed on the Custom tab', () => { + const { panel, onSelect } = makePanel({ initialTab: 'custom' }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Install from a GitHub URL'); + expect(out).toContain('╭'); + + for (const ch of 'https://github.com/owner/repo') { + panel.handleInput(ch); + } + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ - kind: 'toggle', - id: 'kimi-datasource', - enabled: false, + kind: 'install-source', + source: 'https://github.com/owner/repo', }); }); - it('issues a remove request from the overview on D', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('d'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'kimi-datasource' }); - }); - - it('opens MCP server management from the overview on M', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - colors: darkColors, - onSelect, - onCancel: vi.fn(), - }); - - picker.handleInput('m'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'kimi-datasource' }); - }); - it('toggles MCP servers from the MCP selector', () => { const selections: PluginMcpSelection[] = []; const picker = new PluginMcpSelectorComponent({ @@ -378,6 +507,8 @@ describe('plugins selector dialogs', () => { skillCount: 1, mcpServerCount: 1, enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z', @@ -396,7 +527,6 @@ describe('plugins selector dialogs', () => { ], diagnostics: [], }, - colors: darkColors, onSelect: (selection) => { selections.push(selection); }, @@ -416,40 +546,11 @@ describe('plugins selector dialogs', () => { ]); }); - it('renders plugin action hints inline on the overview row', () => { - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'kimi-datasource', - displayName: 'Kimi Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - selectedId: 'kimi-datasource', - pluginHint: { id: 'kimi-datasource', text: 'pending /new' }, - colors: darkColors, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - - expect(out).toContain('? Kimi Datasource enabled pending /new'); - }); - it('defaults plugin removal confirmation to cancel', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, @@ -470,13 +571,12 @@ describe('plugins selector dialogs', () => { const picker = new PluginRemoveConfirmComponent({ id: 'kimi-datasource', displayName: 'Kimi Datasource', - colors: darkColors, onDone: (result) => { results.push(result); }, }); - picker.handleInput(''); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); expect(strip(raw)).toContain('Enter/Space select'); // The destructive option label keeps its danger styling (error + bold). @@ -486,4 +586,49 @@ describe('plugins selector dialogs', () => { expect(results).toEqual([{ kind: 'confirm' }]); }); + + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); + + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); + }); + + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); + + picker.handleInput('\r'); + + expect(results).toEqual([{ kind: 'confirm' }]); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts index 858289f42..6596cf705 100644 --- a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -24,7 +24,6 @@ function rendered(component: ProviderManagerComponent, width = 120): string { function makeComponent(overrides: Partial<ProviderManagerOptions> = {}): ProviderManagerComponent { return new ProviderManagerComponent({ providers: {} as Record<string, ProviderConfig>, - colors: darkColors, onAdd: vi.fn(), onDeleteSource: vi.fn(), onClose: vi.fn(), diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 98604f43e..812ac0d94 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,10 +1,10 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -49,7 +49,6 @@ function makeDialog( collected.push(response.answers); methods.push(response.method); }, - darkColors, 6, onToggleToolOutput, ); @@ -86,9 +85,9 @@ describe('QuestionDialogComponent', () => { expect(review).not.toContain('? Ready to submit your answers?'); expect(review).not.toContain('Please answer all questions before submitting.'); expect(reviewRaw).toContain( - chalk.hex(darkColors.text).bold(' Review your answer before submit'), + currentTheme.boldFg('text', ' Review your answer before submit'), ); - expect(reviewRaw).toContain(chalk.hex(darkColors.text)(' Ready to submit your answers?')); + expect(reviewRaw).toContain(currentTheme.fg('text', ' Ready to submit your answers?')); expect(review).toContain('B1'); expect(review).toContain('A2'); @@ -293,8 +292,8 @@ describe('QuestionDialogComponent', () => { dialog.handleInput('\u001B[D'); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.hex(darkColors.success).bold(' → [1] A')); - expect(out).not.toContain(chalk.hex(darkColors.primary)(' → [1] A')); + expect(out).toContain(currentTheme.boldFg('success', ' → [1] A')); + expect(out).not.toContain(currentTheme.fg('primary', ' → [1] A')); }); it('stretches the border to the full available width', () => { @@ -354,7 +353,9 @@ describe('QuestionDialogComponent', () => { const { dialog } = makeDialog(pending); const out = dialog.render(80).join('\n'); - expect(out).toContain(chalk.bgHex(darkColors.primary).hex(darkColors.text).bold(' First ')); + expect(out).toContain( + chalk.bgHex(currentTheme.color('primary')).hex(currentTheme.color('text')).bold(' First '), + ); expect(out).not.toContain('(●) First'); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 13c014a64..3c885488b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -1,8 +1,7 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; -import { getColorPalette } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); @@ -12,6 +11,9 @@ function renderPlain(component: SessionPickerComponent, width = 120): string { return stripAnsi(component.render(width).join('\n')); } +const BACKSPACE = String.fromCodePoint(127); +const ESC = String.fromCodePoint(27); + describe('SessionPickerComponent', () => { afterEach(() => { vi.restoreAllMocks(); @@ -24,7 +26,6 @@ describe('SessionPickerComponent', () => { sessions: [], loading: false, currentSessionId: '', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), onCtrlC, @@ -59,7 +60,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -87,7 +87,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -117,7 +116,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -144,7 +142,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -178,7 +175,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_current', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -205,7 +201,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -241,7 +236,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_other', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -270,7 +264,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: 'ses_cjk_long_session_id_value', - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -305,7 +298,6 @@ describe('SessionPickerComponent', () => { ], loading: false, currentSessionId: id, - colors: getColorPalette('dark'), onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -319,4 +311,402 @@ describe('SessionPickerComponent', () => { } } }); + + it('calls onToggleScope with the selected session id when Ctrl+A is pressed', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_a', + title: 'Session A', + work_dir: '/tmp/project-a', + updated_at: 1, + }, + { + id: 'ses_b', + title: 'Session B', + work_dir: '/tmp/project-b', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('\u001B[B'); + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_b'); + }); + + it('calls onToggleScope with the current session id when Ctrl+A is pressed with no sessions', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: 'ses_current', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_current'); + }); + + it('renders the Ctrl+A all-sessions hint when the current cwd has no sessions', () => { + const component = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: 'ses_current', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('No sessions found.'); + expect(output).toContain('Ctrl+A all'); + }); + + it('renders all-sessions scope header and Ctrl+A current-cwd hint', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_all', + title: 'All scope session', + work_dir: '/tmp/project', + updated_at: 1, + }, + ], + loading: false, + currentSessionId: '', + scope: 'all', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('All sessions'); + expect(output).toContain('↑↓ navigate · Ctrl+A current cwd · Enter select · Esc cancel'); + }); + + it('selects the full session row on Enter', () => { + const onSelect = vi.fn(); + const session = { + id: 'ses_row', + title: 'Row session', + work_dir: '/tmp/project-row', + updated_at: 1, + }; + const component = new SessionPickerComponent({ + sessions: [session], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(session); + }); + + it('loads the next 50 sessions after moving past the loaded page', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const component = new SessionPickerComponent({ + sessions: Array.from({ length: 120 }, (_, index) => ({ + id: `ses_${String(index).padStart(4, '0')}`, + title: `Session ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - index * 1000, + })), + loading: false, + currentSessionId: '', + scope: 'all', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (let i = 0; i < 50; i++) { + component.handleInput('\u001B[B'); + } + + const output = renderPlain(component); + + expect(output).toContain('Session 0050'); + expect(output).toContain('Showing 49-52 of 100 loaded / 120 sessions'); + }); + + it('keeps initial selected session id and loads enough pages for it', () => { + const component = new SessionPickerComponent({ + sessions: Array.from({ length: 80 }, (_, index) => ({ + id: `ses_${String(index).padStart(4, '0')}`, + title: `Session ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: index, + })), + loading: false, + currentSessionId: '', + scope: 'all', + initialSelectedSessionId: 'ses_0070', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('Session 0070'); + expect(output).toContain('Showing 69-72 of 80 sessions'); + }); + + it('shows type-to-search copy only when the query is empty', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_search_copy', + title: 'Search copy session', + work_dir: '/tmp/project', + updated_at: 1, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('Sessions (type to search)'); + expect(output).not.toContain('Search:'); + + component.handleInput('x'); + const searchOutput = renderPlain(component); + + expect(searchOutput).toContain('Search: x'); + expect(searchOutput).not.toContain('Sessions (type to search)'); + }); + + it('fuzzy-filters by session name only when typing', () => { + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + last_prompt: 'needleprompt do not match', + work_dir: '/tmp/needleprompt', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + last_prompt: 'other prompt', + work_dir: '/tmp/other', + updated_at: 2, + }, + { + id: 'ses_fuzzy', + title: 'N1e2e3d4l5e session', + last_prompt: 'prompt only', + work_dir: '/tmp/project', + updated_at: 3, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('n'); + component.handleInput('e'); + component.handleInput('e'); + component.handleInput('d'); + component.handleInput('l'); + component.handleInput('e'); + + const output = renderPlain(component); + + expect(output).toContain('Search: needle'); + expect(output).toContain('N1e2e3d4l5e session'); + expect(output).not.toContain('Alpha session'); + expect(output).not.toContain('Beta session'); + }); + + it('clears the query on Backspace and cancels on Esc only after the query is empty', () => { + const onCancel = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel, + }); + + component.handleInput('z'); + expect(renderPlain(component)).toContain('Search: z'); + + component.handleInput(BACKSPACE); + expect(renderPlain(component)).not.toContain('Search:'); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput('z'); + expect(renderPlain(component)).toContain('Search: z'); + + component.handleInput(ESC); + expect(renderPlain(component)).not.toContain('Search:'); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput(ESC); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('selects the filtered session row on Enter', () => { + const onSelect = vi.fn(); + const target = { + id: 'ses_gamma', + title: 'Gamma session', + work_dir: '/tmp/project-gamma', + updated_at: 3, + }; + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project-alpha', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project-beta', + updated_at: 2, + }, + target, + ], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('g'); + component.handleInput('a'); + component.handleInput('m'); + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(target); + }); + + it('loads the next 50 matching sessions after moving past the filtered page', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const component = new SessionPickerComponent({ + sessions: [ + ...Array.from({ length: 80 }, (_, index) => ({ + id: `ses_needle_${String(index).padStart(4, '0')}`, + title: `Needle ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - index * 1000, + })), + ...Array.from({ length: 40 }, (_, index) => ({ + id: `ses_other_${String(index).padStart(4, '0')}`, + title: `Other ${String(index).padStart(4, '0')}`, + work_dir: '/tmp/project', + updated_at: now - (80 + index) * 1000, + })), + ], + loading: false, + currentSessionId: '', + pageSize: 50, + maxVisibleSessions: 4, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('n'); + component.handleInput('e'); + component.handleInput('e'); + component.handleInput('d'); + component.handleInput('l'); + component.handleInput('e'); + for (let i = 0; i < 50; i++) { + component.handleInput('\u001B[B'); + } + + const output = renderPlain(component); + + expect(output).toContain('Needle 0050'); + expect(output).toContain('Showing 49-52 of 80 loaded / 80 matches'); + }); + + it('calls onToggleScope with the selected filtered session id when Ctrl+A is pressed', () => { + const onToggleScope = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_alpha', + title: 'Alpha session', + work_dir: '/tmp/project-a', + updated_at: 1, + }, + { + id: 'ses_beta', + title: 'Beta session', + work_dir: '/tmp/project-b', + updated_at: 2, + }, + ], + loading: false, + currentSessionId: '', + scope: 'cwd', + onSelect: vi.fn(), + onCancel: vi.fn(), + onToggleScope, + }); + + component.handleInput('b'); + component.handleInput('e'); + component.handleInput('t'); + component.handleInput('a'); + component.handleInput('\u0001'); + + expect(onToggleScope).toHaveBeenCalledOnce(); + expect(onToggleScope).toHaveBeenCalledWith('ses_beta'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index d545103af..28fc4210f 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -3,7 +3,8 @@ import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; const ESC = String.fromCodePoint(27); const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); @@ -34,8 +35,7 @@ function make(): { gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', - currentThinking: false, - colors: darkColors, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -45,12 +45,15 @@ function make(): { describe('TabbedModelSelectorComponent', () => { let previousLevel: typeof chalk.level; + const previousPalette = currentTheme.palette; beforeAll(() => { previousLevel = chalk.level; chalk.level = 3; + currentTheme.setPalette(darkColors); }); afterAll(() => { chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); }); it('renders an "All" + per-provider tab strip', () => { @@ -67,6 +70,25 @@ describe('TabbedModelSelectorComponent', () => { expect(raw).toContain(PRIMARY_BG); }); + it('repaints the tab strip from the current theme palette without remounting', () => { + const { component } = make(); + const stripLine = (lines: string[]): string => + lines.find((l) => l.includes('All') && l.includes('openai')) ?? ''; + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkStrip = stripLine(component.render(120)); + currentTheme.setPalette(lightColors); + const lightStrip = stripLine(component.render(120)); + // The strip is drawn from currentTheme.palette at render time; a + // construction-time palette snapshot would render the same strip after + // the switch. + expect(darkStrip).not.toBe(lightStrip); + } finally { + currentTheme.setPalette(previous); + } + }); + it('opens on the All tab by default (showing every provider\'s models)', () => { const out = strip(make().component.render(120).join('\n')); expect(out).toContain('Kimi K2'); @@ -88,7 +110,7 @@ describe('TabbedModelSelectorComponent', () => { const { component, onSelect } = make(); component.handleInput(RIGHT); // toggle thinking on for k2 component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: 'on' }); }); it('frames the tab strip with a blank line above and below it', () => { diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6545af266..96598f6f5 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -3,17 +3,19 @@ import type { AutocompleteProvider, AutocompleteSuggestions, TUI, -} from '@earendil-works/pi-tui'; -import { describe, expect, it, vi } from 'vitest'; +} from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; -import { getColorPalette } from '#/tui/theme/index'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), + terminal: { rows: 40, cols: 120 }, } as unknown as TUI; - return new CustomEditor(tui, { ...getColorPalette('dark') }); + return new CustomEditor(tui); } async function flushAutocomplete(): Promise<void> { @@ -28,6 +30,22 @@ function providerReturning(items: AutocompleteItem[]): AutocompleteProvider { }; } +function providerRecordingForce(items: AutocompleteItem[]): { + provider: AutocompleteProvider; + calls: Array<{ force: boolean | undefined; text: string }>; +} { + const calls: Array<{ force: boolean | undefined; text: string }> = []; + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol, options) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + calls.push({ force: options?.force, text }); + return { items, prefix: text }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + return { provider, calls }; +} + describe('CustomEditor autocomplete Escape handling', () => { it('escape closes a visible slash command menu without firing app-level escape', async () => { const editor = makeEditor(); @@ -54,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => { getSuggestions: vi.fn( () => new Promise<AutocompleteSuggestions | null>((resolve) => { - resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + resolveSuggestions = (items) => { + resolve({ items, prefix: '/' }); + }; }), ), applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), @@ -73,6 +93,368 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); +describe('CustomEditor onNonEscapeInput', () => { + it('fires for a printable key and not for a lone Escape', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('a'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + + editor.handleInput('\u001B'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); + + it('fires for control keys so they break a pending double-Esc', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('\u0003'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); +}); + +describe('CustomEditor slash argument completion refresh', () => { + it('reopens /add-dir directory completions after tab completion and entering slash', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => + prefix === '/' ? [{ value: '/tmp/shared/', label: 'shared/' }] : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); + + editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('reopens the next directory level after tab-accepting a directory', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => { + if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }]; + if (prefix === '/tmp/shared/') + return [{ value: '/tmp/shared/child/', label: 'child/' }]; + return null; + }, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); + + editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /tmp/shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); +}); + +describe('CustomEditor slash command name Tab-accept', () => { + it('reopens subcommand completions after Tab-accepting a slash command name', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'goal', + description: 'Manage goals', + getArgumentCompletions: (prefix) => + prefix === '' + ? [ + { value: 'status', label: 'status' }, + { value: 'pause', label: 'pause' }, + ] + : null, + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/go') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/goal '); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('does not fall back to file completions for a command without subcommands', async () => { + const editor = makeEditor(); + const provider = new FileMentionProvider( + [ + { + name: 'compact', + description: 'Compact context', + }, + ], + process.cwd(), + null, + ); + editor.setAutocompleteProvider(provider); + + for (const char of '/comp') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/compact '); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor @ mention completion refresh', () => { + it('reopens the next directory level after tab-accepting an @ directory', async () => { + const editor = makeEditor(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn( + async ( + lines: string[], + cursorLine: number, + cursorCol: number, + ): Promise<AutocompleteSuggestions> => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + if (text === '@') { + return { items: [{ value: '@shared/', label: 'shared/' }], prefix: '@' }; + } + if (text === '@shared/') { + return { items: [{ value: '@shared/child/', label: 'child/' }], prefix: '@shared/' }; + } + return { items: [], prefix: '' }; + }, + ), + applyCompletion: vi.fn( + ( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) => { + const line = lines[cursorLine] ?? ''; + const beforePrefix = line.slice(0, cursorCol - prefix.length); + const afterCursor = line.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length, + }; + }, + ), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('@shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); + }); +}); + +describe('CustomEditor Tab key handling', () => { + it('does not open autocomplete when Tab is pressed with the dropdown closed', async () => { + const editor = makeEditor(); + const provider = providerReturning([{ value: '@src/file.ts', label: 'file.ts' }]); + editor.setAutocompleteProvider(provider); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + expect(provider.getSuggestions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor slash argument hint', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('renders the argument hint after a command with a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('renders the argument hint after a command without a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('hides the hint once an argument is typed', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir foo') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render a hint for an unknown command', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/unknown ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render the argument hint in bash mode', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not highlight the slash token in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const contentLine = editor.render(90)[1] ?? ''; + const tokenIdx = contentLine.indexOf('/add-dir'); + expect(tokenIdx).toBeGreaterThan(-1); + // Prompt mode wraps `/add-dir` in a primary-colour ANSI sequence; in bash + // mode the token is plain text, so the byte right before it is a space. + expect(contentLine[tokenIdx - 1]).toBe(' '); + }); +}); + +describe('CustomEditor slash menu description wrapping', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => { + const editor = makeEditor(); + const description = 'word '.repeat(60).trim(); + editor.setAutocompleteProvider( + providerReturning([{ value: 'deep', label: 'deep', description }]), + ); + + editor.handleInput('/'); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('word')); + expect(descriptionLines).toHaveLength(2); + expect(descriptionLines[1]).toContain('…'); + }); + + it('keeps non-slash autocomplete descriptions on a single line', async () => { + const editor = makeEditor(); + const description = 'path '.repeat(60).trim(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async () => ({ + items: [{ value: '@src/file.ts', label: 'file.ts', description }], + prefix: '@f', + })), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ + lines, + cursorLine, + cursorCol, + })), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + // @-mention requests are debounced (20ms), unlike slash menus. + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + const plain = editor.render(90).map(stripAnsi); + const descriptionLines = plain.filter((line) => line.includes('path')); + expect(descriptionLines).toHaveLength(1); + expect(plain.join('\n')).not.toContain('…'); + }); +}); + describe('CustomEditor Kitty key release handling', () => { it('ignores Kitty key release events instead of inserting their CSI-u payload', () => { const editor = makeEditor(); @@ -85,8 +467,8 @@ describe('CustomEditor Kitty key release handling', () => { }); describe('CustomEditor paste marker expansion', () => { - const PASTE_START = '\x1b[200~'; - const PASTE_END = '\x1b[201~'; + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; function simulateLargePaste(editor: CustomEditor, content: string): void { editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); @@ -151,7 +533,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\x16'); + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); @@ -195,7 +577,7 @@ describe('CustomEditor paste marker expansion', () => { // Split: PASTE_START in chunk 1, paste-end split across chunk 2 and 3 editor.handleInput(`${PASTE_START}data`); - editor.handleInput('\x1b[20'); + editor.handleInput('\u001B[20'); editor.handleInput('1~'); expect(editor.getText()).toContain(longText); @@ -208,19 +590,6 @@ describe('CustomEditor paste marker expansion', () => { }); describe('CustomEditor shortcut telemetry hooks', () => { - it('reports newline shortcuts, including Ctrl-J, before delegating to the base editor', () => { - const editor = makeEditor(); - const onInsertNewline = vi.fn(); - editor.onInsertNewline = onInsertNewline; - - editor.handleInput('a'); - editor.handleInput('\n'); - editor.handleInput('\u001B[106;5u'); - - expect(onInsertNewline).toHaveBeenCalledTimes(2); - expect(editor.getText()).toBe('a\n\n'); - }); - it('reports undo shortcuts before delegating to the base editor', () => { const editor = makeEditor(); const onUndo = vi.fn(); @@ -231,4 +600,287 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onUndo).toHaveBeenCalledOnce(); }); + + it('invokes onToggleTodoExpand on Ctrl+T', () => { + const editor = makeEditor(); + const onToggleTodoExpand = vi.fn().mockReturnValue(true); + editor.onToggleTodoExpand = onToggleTodoExpand; + + editor.handleInput('\u0014'); + + expect(onToggleTodoExpand).toHaveBeenCalledOnce(); + }); +}); + +describe('CustomEditor bash mode border label', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('shows "! shell mode" on the top border in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top.startsWith('╭')).toBe(true); + expect(top).toContain('! shell mode'); + expect(top.endsWith('╮')).toBe(true); + }); + + it('does not show the shell mode label in prompt mode', () => { + const editor = makeEditor(); + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top).not.toContain('! shell mode'); + }); + + it('keeps the top border at full width when the label is present', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const width = 90; + const top = stripAnsi(editor.render(width)[0] ?? ''); + expect(top).toHaveLength(width); + }); +}); + +describe('CustomEditor bash mode via paste', () => { + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; + + it('enters bash mode and strips the leading ! when !cmd is pasted into an empty prompt', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe('ls'); + expect(modes).toEqual(['bash']); + }); + + it('enters bash mode on a bare pasted ! with an empty buffer', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}!${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('does not enter bash mode when pasting !cmd into a non-empty prompt', () => { + const editor = makeEditor(); + editor.handleInput('hello'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toContain('hello'); + expect(editor.getText()).toContain('!ls'); + }); + + it('does not enter bash mode for a pasted command without a leading !', () => { + const editor = makeEditor(); + editor.handleInput(`${PASTE_START}ls${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toBe('ls'); + }); + + it('keeps the typed ! behaviour (bash mode, empty buffer)', () => { + const editor = makeEditor(); + editor.handleInput('!'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); + + it('enters bash mode on a CSI-u encoded ! keystroke (Kitty/VSCode terminals)', () => { + const editor = makeEditor(); + editor.handleInput('\u001B[33u'); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); + }); +}); + +describe('CustomEditor bash mode file completion', () => { + it('triggers file completion (force:true) for a leading / in bash mode, not the slash menu', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'auto', label: 'auto' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('triggers file completion (force:true) for an inline / in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'etc', label: 'etc' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of 'ls /') { + editor.handleInput(char); + } + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: 'ls /' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('keeps force:false (slash menu) for a leading / in prompt mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'help', label: 'help' }]); + editor.setAutocompleteProvider(provider); + // inputMode defaults to 'prompt' + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: false, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); + }); + + it('never falls back to force:false for a slash-shaped command in bash mode', async () => { + const editor = makeEditor(); + const { provider, calls } = providerRecordingForce([{ value: 'list', label: 'list' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + + // A force:false request would let pi-tui's own slash-command handling pop + // up subcommand completions for `/add-dir `. Bash mode must only ever + // request force:true path completion. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.force === true)).toBe(true); + }); +}); + +describe('CustomEditor full re-render on autocomplete close', () => { + function makeEditorWithRenderSpy(contentLines: number): { + editor: CustomEditor; + requestRender: ReturnType<typeof vi.fn>; + } { + const requestRender = vi.fn(); + const tui = { + requestRender, + terminal: { rows: 40, cols: 120 }, + render: vi.fn(() => Array.from({ length: contentLines }, () => '')), + } as unknown as TUI; + return { editor: new CustomEditor(tui), requestRender }; + } + + // Drive one render frame so the render-edge detector observes the menu state. + function renderFrame(editor: CustomEditor): void { + editor.render(120); + } + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); // record wasShowing = true + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); // close edge -> schedule helper + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); + + it('keeps differential rendering when the content fits on one screen', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(10); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).not.toHaveBeenCalledWith(true); + }); + + it('forces a full re-render when the content exactly fills one screen', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(40); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); + + it('does not force a full re-render inside tmux', async () => { + vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0'); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); + + editor.handleInput(''); + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); + await flushAutocomplete(); + expect(requestRender).not.toHaveBeenCalledWith(true); + }); + + it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => { + vi.stubEnv('TMUX', ''); + const { editor, requestRender } = makeEditorWithRenderSpy(50); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + if (!text.startsWith('/')) return { items: [], prefix: text }; + return { items: [{ value: 'help', label: 'help' }], prefix: '/' }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + renderFrame(editor); // record wasShowing = true + + editor.handleInput(''); // Backspace deletes the '/' + await flushAutocomplete(); + await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle + expect(editor.isShowingAutocomplete()).toBe(false); + + renderFrame(editor); // close edge -> schedule helper + await flushAutocomplete(); + expect(requestRender).toHaveBeenCalledWith(true); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index ec0bea1cd..b53b49a83 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,8 +1,9 @@ +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -11,6 +12,17 @@ function ctrl(): AbortSignal { } const NO_FD = null; + +function resolveFdPath(): string | null { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, ['fd'], { encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return null; + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +} + +const FD_PATH = resolveFdPath(); +const IS_FD_INSTALLED = Boolean(FD_PATH); const GOAL_COMMAND = { name: 'goal', description: 'Start or manage a goal', @@ -25,17 +37,67 @@ const GOAL_COMMAND = { : null, }; +const NEW_COMMAND = { + name: 'new', + aliases: ['clear'], + description: 'Start a fresh session in the current workspace', +}; + +const LARK_CALENDAR_COMMAND = { + name: 'skill:lark-calendar', + aliases: [], + description: 'Manage Lark calendars', +}; + +const HELP_COMMAND = { + name: 'help', + aliases: ['h'], + description: 'Show help', +}; + +const HELP_FULL_COMMAND = { + name: 'help', + aliases: ['h', '?'], + description: 'Show help', +}; + +const ADD_DIR_COMMAND = { + name: 'add-dir', + description: 'Add or list an additional workspace directory', + getArgumentCompletions: (prefix: string) => + prefix === '/' + ? [ + { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, + ] + : null, +}; + describe('FileMentionProvider', () => { let workDir: string; + let extraDirs: string[]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-')); + extraDirs = []; }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); + for (const extraDir of extraDirs) { + rmSync(extraDir, { recursive: true, force: true }); + } }); + function createExtraDir(): string { + const extraDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-extra-')); + extraDirs.push(extraDir); + return extraDir; + } + it('returns null when there is no completable prefix', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); @@ -49,6 +111,21 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('opens @ file mention when typed in the middle of a slash command argument', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + // Cursor sits in the middle of the /goal argument text, right after a + // freshly typed `@`. The slash-argument guard must not suppress the @ + // file list here. + const line = '/goal Fix the @checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal Fix the @'.length, { + signal: ctrl(), + }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); + }); + it('still completes slash arguments at the end of an empty argument', async () => { const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal '; @@ -58,6 +135,135 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(completedLine).toBe('/add-dir '); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + + it('searches slash command aliases and displays aliases in the command label', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/clear'); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + }); + + it('prefers exact alias matches over fuzzy skill matches', async () => { + const provider = new FileMentionProvider( + [NEW_COMMAND, LARK_CALENDAR_COMMAND], + workDir, + NO_FD, + ); + const line = '/clear'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new (clear)', + }); + expect(result!.items[0]?.value).not.toBe('skill:lark-calendar'); + }); + + it('does not show aliases when the primary name already matches', async () => { + const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); + const line = '/h'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help', + }); + }); + + it('does not show aliases in labels when query is empty', async () => { + const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); + const line = '/'; + + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'new', + label: 'new', + }); + }); + + it('includes the argument hint in the description like the inner provider does', async () => { + const provider = new FileMentionProvider( + [{ name: 'goal', description: 'Start or manage a goal', argumentHint: '<objective>' }], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/go'], 0, 3, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'goal', + description: '<objective> — Start or manage a goal', + }); + }); + + it('joins multiple aliases with an ASCII comma in the label', async () => { + const provider = new FileMentionProvider([HELP_FULL_COMMAND], workDir, NO_FD); + // '?' only matches the alias, not the primary name, so the label must + // list the aliases. + const result = await provider.getSuggestions(['/?'], 0, 2, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]).toMatchObject({ + value: 'help', + label: 'help (h, ?)', + }); + }); + + it('returns null for a bare slash when no commands are registered', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['/'], 0, 1, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('ranks primary-name matches above alias matches with equal scores', async () => { + const provider = new FileMentionProvider( + [ + { name: 'bar', aliases: ['foo'], description: 'Bar command' }, + { name: 'foo', aliases: [], description: 'Foo command' }, + ], + workDir, + NO_FD, + ); + + const result = await provider.getSuggestions(['/foo'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items[0]?.value).toBe('foo'); + expect(result!.items[1]).toMatchObject({ + value: 'bar', + label: 'bar (foo)', + }); + }); + it('does not turn leading-whitespace slash into root path completion', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl() }); @@ -90,15 +296,118 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { - writeFileSync(join(workDir, 'README.md'), 'readme'); - const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); - expect(result).toBeNull(); + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); }); + it.runIf(IS_FD_INSTALLED)( + 'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner', + async () => { + // Fill cwd with enough entries to push the filesystem fallback past its + // 2000-entry scan cap, so it would never reach the additional root. fd + // searches each root independently and still finds the deep target. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it.runIf(IS_FD_INSTALLED)( + 'treats a bare fd command name as executable and resolves it via PATH', + async () => { + // A bare "fd" (system PATH lookup) must not be mistaken for unavailable; + // otherwise the large cwd would push the fallback scanner past its cap + // and hide the deep target in the additional root. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'), + ); + expect(overlapItems).toHaveLength(1); + }); + + it.runIf(IS_FD_INSTALLED)( + 'does not bypass fd filtering with filesystem suggestions when fd returns no matches', + async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, FD_PATH!); + + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); + + expect(result).toBeNull(); + }, + ); + it('filesystem fallback returns folders and excludes .git', async () => { mkdirSync(join(workDir, 'src')); mkdirSync(join(workDir, '.git')); @@ -168,4 +477,167 @@ describe('FileMentionProvider', () => { ); expect(dir.lines[0]).toBe('hey @src/'); }); + + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); + + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); + }); + + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); + + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); + + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); + }); + + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); + + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); + + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index d137558b9..6d3145b14 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -75,4 +75,32 @@ describe('wrapWithSideBorders', () => { // first column was a space → replaced with │; last column was 'c' → kept expect(out[1]).toBe('│ abc'); }); + + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index 1ac26c70e..d47f29b56 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -20,7 +20,7 @@ function expectHighlighted(out: string, token: string): void { describe('highlightFirstSlashToken', () => { it('colours /cmd when line starts with a slash', () => { - const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); + const out = highlightFirstSlashToken(' /help rest of input', 'primary'); expect(out).toBeDefined(); // Visible text unchanged expect(strip(out!)).toBe(' /help rest of input'); @@ -29,7 +29,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours next in /goal next', () => { - const out = highlightFirstSlashToken('/goal next Ship feature X', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next Ship feature X', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next Ship feature X'); expectHighlighted(out!, '/goal'); @@ -38,7 +38,7 @@ describe('highlightFirstSlashToken', () => { }); it('colours manage in /goal next manage', () => { - const out = highlightFirstSlashToken('/goal next manage', '#ff00aa'); + const out = highlightFirstSlashToken('/goal next manage', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/goal next manage'); expectHighlighted(out!, '/goal'); @@ -47,19 +47,19 @@ describe('highlightFirstSlashToken', () => { }); it('returns undefined when the line has no slash', () => { - expect(highlightFirstSlashToken('hello world', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('hello world', 'primary')).toBeUndefined(); }); it('returns undefined when slash is not at the leading position', () => { - expect(highlightFirstSlashToken(' hello /not-cmd', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken(' hello /not-cmd', 'primary')).toBeUndefined(); }); it('returns undefined for path-like slash tokens', () => { - expect(highlightFirstSlashToken('/user/desktop/ foo', '#ff00aa')).toBeUndefined(); + expect(highlightFirstSlashToken('/user/desktop/ foo', 'primary')).toBeUndefined(); }); it('handles /token at end of line (no trailing whitespace)', () => { - const out = highlightFirstSlashToken('/exit', '#ff00aa'); + const out = highlightFirstSlashToken('/exit', 'primary'); expect(out).toBeDefined(); expect(strip(out!)).toBe('/exit'); }); @@ -68,7 +68,7 @@ describe('highlightFirstSlashToken', () => { // Simulate pi-tui Editor inserting an inverse-video cursor marker // somewhere after the slash token. const line = '/help x\u001B[7m \u001B[0m'; - const out = highlightFirstSlashToken(line, '#ff00aa'); + const out = highlightFirstSlashToken(line, 'primary'); expect(out).toBeDefined(); // Stripped visible content unchanged expect(strip(out!)).toBe(strip(line)); @@ -77,11 +77,11 @@ describe('highlightFirstSlashToken', () => { }); it('only paints the first token, not other slashes further along', () => { - const out = highlightFirstSlashToken('/a /b', '#ff00aa'); + const out = highlightFirstSlashToken('/a /b', 'primary'); expect(out).toBeDefined(); // Count the SGR opens — should be exactly one for /a. const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; - expect(opens).toBeGreaterThanOrEqual(2); // chalk hex+bold open and reset(s) + expect(opens).toBeGreaterThanOrEqual(2); // chalk bold+fg open and reset(s) // /b should remain plain — the substring " /b" exists verbatim. expect(out!).toContain(' /b'); }); diff --git a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts new file mode 100644 index 000000000..c4147ab40 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -0,0 +1,141 @@ +import { visibleWidth, type SelectItem, type SelectListTheme } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; + +/** Marker theme so assertions can see which style hook painted each part. */ +const MARKER_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => `[S]${s}`, + description: (s) => `[D]${s}`, + scrollInfo: (s) => `[I]${s}`, + noMatch: (s) => `[N]${s}`, +}; + +const IDENTITY_THEME: SelectListTheme = { + selectedPrefix: (s) => s, + selectedText: (s) => s, + description: (s) => s, + scrollInfo: (s) => s, + noMatch: (s) => s, +}; + +/** Mirrors pi-tui's slash command layout (editor.js). */ +const SLASH_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32 }; + +// With two 4-char labels and SLASH_LAYOUT at width 80, the primary column is +// 12 wide: prefix(2) + label(4) + spacing(8) puts descriptions at column 14 +// with 64 columns of room (80 - 14 - 2 safety). +const DESCRIPTION_INDENT = ' '.repeat(14); + +function makeList(items: SelectItem[], maxVisible = 5): WrappingSelectList { + return new WrappingSelectList(items, maxVisible, MARKER_THEME, SLASH_LAYOUT); +} + +describe('WrappingSelectList', () => { + it('renders short descriptions on a single line', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] Second command', + ]); + }); + + it('wraps a long description onto a second indented line without an ellipsis', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { + value: 'init', + label: 'init', + description: + 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt', + }, + ]).render(80); + + expect(lines).toEqual([ + '[S]→ goal First command', + ' init[D] lorem ipsum dolor sit amet consectetur adipiscing elit sed do', + `[D]${DESCRIPTION_INDENT}eiusmod tempor incididunt`, + ]); + }); + + it('caps descriptions at two lines and ellipsizes the overflow', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description }, + ]).render(80); + + expect(lines).toHaveLength(3); + expect(lines[1]).toMatch(/^ {2}init\[D\] {8}lorem ipsum/); + expect(lines[2]).toMatch(new RegExp(`^\\[D\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]!.endsWith('…')).toBe(true); + }); + + it('paints every line of the selected item with the selected style', () => { + const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); + const lines = makeList([ + { value: 'goal', label: 'goal', description }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(80); + + expect(lines[0]).toMatch(/^\[S\]→ goal {8}lorem ipsum/); + expect(lines[1]).toMatch(new RegExp(`^\\[S\\]${DESCRIPTION_INDENT}`)); + expect(lines[2]).toBe(' init[D] Second command'); + }); + + it('falls back to primary-only single lines on narrow widths', () => { + const lines = makeList([ + { value: 'goal', label: 'goal', description: 'First command' }, + { value: 'init', label: 'init', description: 'Second command' }, + ]).render(40); + + expect(lines).toEqual(['[S]→ goal', ' init']); + }); + + it('keeps the scroll indicator when items overflow maxVisible', () => { + const items = Array.from({ length: 7 }, (_, i) => ({ + value: `cmd${i}`, + label: `cmd${i}`, + description: 'Short', + })); + const lines = makeList(items, 5).render(80); + + expect(lines).toHaveLength(6); + expect(lines[5]).toBe('[I] (1/7)'); + }); + + it('does not leak ANSI resets into themed lines when the primary name is truncated', () => { + const description = 'Use when about to claim work is complete fixed or passing before committing'; + const lines = makeList([ + { value: 'verify', label: 'skill:verification-before-completion', description }, + { value: 'init', label: 'skill:another-very-long-command-name', description }, + ]).render(80); + + // truncateToWidth appends [0m when it truncates; embedded inside the + // selected/description colouring it would reset the rest of the line. + for (const line of lines) { + expect(line).not.toContain('\u001B'); + } + }); + + it('never emits a line wider than the requested width, including CJK text', () => { + const list = new WrappingSelectList( + [ + { value: 'lark', label: 'skill:lark-calendar', description: '管理飞书日历的技能描述'.repeat(8) }, + { value: 'init', label: 'init', description: 'word '.repeat(60).trim() }, + ], + 5, + IDENTITY_THEME, + SLASH_LAYOUT, + ); + + for (const line of list.render(80)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(80); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index f2a43aabd..d355bb7ed 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -5,9 +5,6 @@ import { renderDiffLines, renderDiffLinesClustered, } from '#/tui/components/media/diff-preview'; -import { getColorPalette } from '#/tui/theme/colors'; - -const COLORS = getColorPalette('dark'); function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -46,7 +43,7 @@ describe('computeDiffLines', () => { describe('renderDiffLines', () => { it('does not show removed count for suppressed trailing deletes', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, true, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', true, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('test.ts'); expect(text).not.toContain('-2'); @@ -59,7 +56,7 @@ describe('renderDiffLines', () => { }); it('shows removed count for complete diffs', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, false, 1, 1); + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', false, 1, 1); const text = stripAnsi(output.join('\n')); expect(text).toContain('-2'); expect(text).toContain('C'); @@ -69,7 +66,7 @@ describe('renderDiffLines', () => { describe('renderDiffLinesClustered', () => { it('renders header with file path and counts', () => { - const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); const text = stripAnsi(out[0]!); expect(text).toContain('+1'); expect(text).toContain('-1'); @@ -77,7 +74,7 @@ describe('renderDiffLinesClustered', () => { }); it('returns header only when there are no changes', () => { - const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts', COLORS); + const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts'); expect(out).toHaveLength(1); expect(stripAnsi(out[0]!)).toContain('foo.ts'); }); @@ -87,7 +84,7 @@ describe('renderDiffLinesClustered', () => { const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); const text = stripAnsi( - renderDiffLinesClustered(oldText, newText, 'f.ts', COLORS, { contextLines: 1 }).join('\n'), + renderDiffLinesClustered(oldText, newText, 'f.ts', { contextLines: 1 }).join('\n'), ); expect(text).toContain('L2'); expect(text).toContain('L3'); @@ -104,7 +101,7 @@ describe('renderDiffLinesClustered', () => { newLines[1] = 'L2X'; // change near top newLines[28] = 'L29X'; // change near bottom const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'), ); @@ -121,7 +118,7 @@ describe('renderDiffLinesClustered', () => { const newLines = oldLines.slice(); newLines[2] = 'L3X'; newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) - const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, }).join('\n'); const text = stripAnsi(out); @@ -144,7 +141,6 @@ describe('renderDiffLinesClustered', () => { oldLines.join('\n'), newLines.join('\n'), 'big.ts', - COLORS, { contextLines: 3, maxLines: 10, @@ -159,6 +155,22 @@ describe('renderDiffLinesClustered', () => { expect(text).toContain('ctrl+o to expand'); }); + it('respects oldStart and newStart for line numbers', () => { + const text = stripAnsi( + renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'f.ts', { + contextLines: 1, + oldStart: 10, + newStart: 20, + }).join('\n'), + ); + // Context lines keep the new (post-edit) line numbers from newStart; + // deleted lines use oldStart; added lines use newStart. + expect(text).toContain(' 20 A'); + expect(text).toContain(' 11 - B'); + expect(text).toContain(' 21 + X'); + expect(text).toContain(' 22 C'); + }); + it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { const oldLines: string[] = []; for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); @@ -167,7 +179,7 @@ describe('renderDiffLinesClustered', () => { newLines[20] = 'L21X'; newLines[40] = 'L41X'; const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { contextLines: 2, maxLines: 6, }).join('\n'), diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts new file mode 100644 index 000000000..b6378f389 --- /dev/null +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -0,0 +1,47 @@ +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; + +const image: ImageAttachment = { + id: 1, + kind: 'image', + bytes: new Uint8Array([137, 80, 78, 71]), + mime: 'image/png', + width: 800, + height: 600, + placeholder: '[image #1 (800×600)]', +}; + +describe('ImageThumbnail', () => { + afterEach(() => { + resetCapabilitiesCache(); + vi.restoreAllMocks(); + }); + + it('keeps rendered output within narrow widths', () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + + const component = new ImageThumbnail(image); + + for (const width of [39, 20, 3, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('does not rebuild inline image children on repeated same-width renders', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + + const bufferFrom = vi.spyOn(Buffer, 'from'); + const component = new ImageThumbnail(image); + bufferFrom.mockClear(); + + component.render(80); + component.render(80); + + expect(bufferFrom).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts new file mode 100644 index 000000000..adf4d3b0b --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -0,0 +1,233 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +function strip(text: string): string { + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +function stubTui(): TUI { + return { + terminal: { rows: 40 }, + requestRender: vi.fn(), + } as unknown as TUI; +} + +function renderText(component: AgentGroupComponent, width = 120): string { + return strip(component.render(width).join('\n')); +} + +function createAgent( + id: string, + description: string, + agentName: string, + ui: TUI, +): ToolCallComponent { + const component = new ToolCallComponent( + { + id, + name: 'Agent', + args: { description }, + }, + undefined, + ui, + ); + component.onSubagentSpawned({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); + return component; +} + +function startAgent(component: ToolCallComponent, id: string, agentName: string): void { + component.onSubagentStarted({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); +} + +describe('AgentGroupComponent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows explicit active breakdown, row state, and waiting fallback', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + running.appendSubToolCall({ + id: 'sub_call_agent_1:read', + name: 'Read', + args: { path: 'src/a.ts' }, + }); + + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + expect(output).toContain('explore · inspect project · 0 tools · 0s · Running'); + expect(output).toContain('Using Read (src/a.ts)'); + expect(output).toContain('coder · write tests · 0 tools · 0s · Waiting'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + a.markBackgrounded(); + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + b.markBackgrounded(); + expect(renderText(group)).not.toContain('Press Ctrl+B to run in background'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); + + it('uses still-working fallback for running agents without recent activity', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Still working…'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('refreshes grouped elapsed time from child subagent timers', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + vi.mocked(ui.requestRender).mockClear(); + + vi.advanceTimersByTime(1_200); + + expect(ui.requestRender).toHaveBeenCalled(); + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 1s'); + expect(renderText(group)).toContain('explore · inspect project · 0 tools · 1s · Running'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('keeps terminal rows explicit while mixed agents are still running', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const done = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const running = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(done, 'call_agent_1', 'explore'); + startAgent(running, 'call_agent_2', 'coder'); + group.attach('call_agent_1', done); + group.attach('call_agent_2', running); + + vi.setSystemTime(12_000); + done.onSubagentCompleted({ resultSummary: 'done' }); + + const mixed = renderText(group); + expect(mixed).toContain('Running 2 agents (1 done, 1 running) · 12s'); + expect(mixed).toContain('explore · inspect project · 0 tools · 12s · ✓ Completed'); + expect(mixed).toContain('coder · write tests · 0 tools · 12s · Running'); + + vi.setSystemTime(15_000); + running.onSubagentFailed({ error: 'review failed' }); + + const terminal = renderText(group); + expect(terminal).toContain('2 agents finished · 15s'); + expect(terminal).toContain('✗ Failed'); + expect(terminal).toContain('Error: review failed'); + expect(terminal).not.toContain('Still working…'); + + group.dispose(); + done.dispose(); + running.dispose(); + }); + + it('renders a detached foreground subagent as backgrounded in the group, even after its ToolResult lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + // Detach `a` (Ctrl+B), then its spawn-success ToolResult lands. + a.markBackgrounded(); + a.setResult({ + tool_call_id: 'call_agent_1', + output: 'agent_id: sub_call_agent_1\nactual_subagent_type: explore\n', + is_error: false, + }); + + const out = renderText(group); + // `a` must show as backgrounded, NOT completed. + expect(out).toContain('◐ backgrounded'); + expect(out).not.toContain('✓ Completed'); + // `b` is still running. + expect(out).toContain('Running'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index c89e77a36..ad6389418 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; import { AgentSwarmProgressComponent, @@ -12,7 +13,7 @@ import { calculateAgentSwarmGridLayout, } from '#/tui/components/messages/agent-swarm-progress'; import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; const DEFAULT_DESCRIPTION = 'Review changed files'; @@ -25,7 +26,6 @@ function createComponent( ): AgentSwarmProgressComponent { return new AgentSwarmProgressComponent({ description: options.description ?? DEFAULT_DESCRIPTION, - colors: options.colors ?? darkColors, requestRender: options.requestRender, availableGridHeight: options.availableGridHeight, }); @@ -57,6 +57,7 @@ function startSubagents(component: AgentSwarmProgressComponent, count: number): afterEach(() => { vi.useRealTimers(); + currentTheme.setPalette(darkColors); }); describe('calculateAgentSwarmGridLayout', () => { @@ -166,6 +167,29 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); + it('repaints from the active palette when the theme changes', () => { + const previousLevel = chalk.level; + chalk.level = 3; // force truecolor so palette differences surface as ANSI + try { + const component = createComponent(); + const titleOf = (): string => { + const line = component.render(100).find((l) => strip(l).includes('Agent Swarm')); + if (line === undefined) throw new Error('title line not found'); + return line; + }; + const before = titleOf(); + + currentTheme.setPalette(lightColors); + const after = titleOf(); + + // Same visible text, different ANSI colours (reads currentTheme live). + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + } + }); + it('renders blank padding around the block without a bottom divider', () => { const component = createComponent(); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 47d0836a9..e078e6dd2 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -1,13 +1,21 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { Markdown, visibleWidth } from '@moonshot-ai/pi-tui'; +import * as cliHighlight from 'cli-highlight'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; +vi.mock('cli-highlight', async () => { + const actual = await vi.importActual<typeof import('cli-highlight')>('cli-highlight'); + return { + ...actual, + highlight: vi.fn(actual.highlight), + }; +}); + function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -19,7 +27,7 @@ describe('AssistantMessageComponent', () => { }); it('uses the stable status bullet without stealing content width', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('abcdef'); @@ -28,10 +36,21 @@ describe('AssistantMessageComponent', () => { expect(visibleWidth(lines[1] ?? '')).toBe(8); }); + it('keeps assistant lines within very narrow widths', () => { + const component = new AssistantMessageComponent(); + component.updateContent('abcdef'); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('renders unknown markdown fence languages as plain text without stderr noise', () => { const stderr = captureProcessWrite('stderr'); try { - const theme = createMarkdownTheme(darkColors); + const theme = createMarkdownTheme(); expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); expect(stderr.text()).not.toContain('Could not find the language'); } finally { @@ -40,7 +59,7 @@ describe('AssistantMessageComponent', () => { }); it('preserves literal hook result XML in normal assistant text', () => { - const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + const component = new AssistantMessageComponent(); component.updateContent('<hook_result hook_event="UserPromptSubmit">\n{}\n</hook_result>'); @@ -50,4 +69,60 @@ describe('AssistantMessageComponent', () => { expect(text).toContain('</hook_result>'); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('reuses the same Markdown child across streaming text updates', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello world'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + expect(strip(component.render(80).join('\n'))).toContain('hello world'); + }); + + it('does not recreate the Markdown child when the text is unchanged', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + }); + + it('rebuilds the Markdown child when transient changes so final render can highlight code', () => { + const component = new AssistantMessageComponent(); + const code = '```ts\nconst x = 1\n```'; + + component.updateContent(code, { transient: true }); + const streaming = (component as any).contentContainer.children[0]; + expect(streaming).toBeInstanceOf(Markdown); + + component.updateContent(code, { transient: false }); + const finalized = (component as any).contentContainer.children[0]; + expect(finalized).toBeInstanceOf(Markdown); + + expect(finalized).not.toBe(streaming); + }); + + it('skips synchronous syntax highlighting in transient markdown themes', () => { + const highlightSpy = vi.mocked(cliHighlight.highlight); + highlightSpy.mockClear(); + const streamingTheme = createMarkdownTheme({ transient: true }); + const finalTheme = createMarkdownTheme(); + const code = 'const x = 1'; + + expect(streamingTheme.highlightCode?.(code, 'typescript')).toEqual([code]); + expect(highlightSpy).not.toHaveBeenCalled(); + + finalTheme.highlightCode?.(code, 'typescript'); + expect(highlightSpy).toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index 89def6690..e8f395ec8 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,8 +1,8 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,30 +10,21 @@ function strip(text: string): string { describe('BackgroundAgentStatusComponent', () => { it('renders started/completed with the shared bullet and failed with a red x marker', () => { - const started = new BackgroundAgentStatusComponent( - { - phase: 'started', - headline: 'explore agent started in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const completed = new BackgroundAgentStatusComponent( - { - phase: 'completed', - headline: 'explore agent completed in background', - detail: 'Explore project structure', - }, - darkColors, - ); - const failed = new BackgroundAgentStatusComponent( - { - phase: 'failed', - headline: 'explore agent failed in background', - detail: 'Explore project structure · boom', - }, - darkColors, - ); + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'explore agent completed in background', + detail: 'Explore project structure', + }); + const failed = new BackgroundAgentStatusComponent({ + phase: 'failed', + headline: 'explore agent failed in background', + detail: 'Explore project structure · boom', + }); const startedLines = started.render(120).map((line) => strip(line).trimEnd()); const completedLines = completed.render(120).map((line) => strip(line).trimEnd()); @@ -53,4 +44,18 @@ describe('BackgroundAgentStatusComponent', () => { '✗ explore agent failed in background (Explore project structure · boom)', ); }); + + it('keeps status lines within very narrow widths', () => { + const component = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 66435dcc8..f098cc931 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,7 +1,8 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; +import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers'; import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -11,9 +12,9 @@ function strip(lines: string[]): string { describe('buildGoalMarker', () => { it('builds lifecycle markers for paused / resumed / blocked', () => { - const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, darkColors, false); - const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, darkColors, false); - const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, darkColors, false); + const paused = buildGoalMarker({ kind: 'lifecycle', status: 'paused' } as GoalChange, false); + const resumed = buildGoalMarker({ kind: 'lifecycle', status: 'active' } as GoalChange, false); + const blocked = buildGoalMarker({ kind: 'lifecycle', status: 'blocked' } as GoalChange, false); expect(strip(paused!.render(80))).toContain('Goal paused'); expect(strip(resumed!.render(80))).toContain('Goal resumed'); expect(strip(blocked!.render(80))).toContain('Goal blocked'); @@ -22,13 +23,11 @@ describe('buildGoalMarker', () => { it('renders user interruption pause and user resume as prominent markers', () => { const paused = buildGoalMarker( { kind: 'lifecycle', status: 'paused', reason: 'Paused after interruption' } as GoalChange, - darkColors, false, 'runtime', ); const resumed = buildGoalMarker( { kind: 'lifecycle', status: 'active' } as GoalChange, - darkColors, false, 'user', ); @@ -43,7 +42,6 @@ describe('buildGoalMarker', () => { it('does not repeat paused for runtime pause reasons', () => { const marker = buildGoalMarker( { kind: 'lifecycle', status: 'paused', reason: 'Paused after runtime error: socket hang up' } as GoalChange, - darkColors, false, 'runtime', ); @@ -51,16 +49,30 @@ describe('buildGoalMarker', () => { expect(strip(marker!.render(80))).toBe('\n● Goal paused after runtime error: socket hang up'); }); + it('keeps long provider pause markers within the terminal width', () => { + const reason = + 'Paused after provider API error: 400 {"error":{"message":"request id: 456043b9-6491-11f1-9425-2221bb1af97c, \\"thinking.enabled\\" is not supported for this model. Use \\"thinking.adaptive\\" and \\"output_config.effort\\" to control thinking behavior.","type":"invalid_request_error"}}'; + const marker = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason } as GoalChange, + false, + 'runtime', + ); + + const width = 80; + expect(strip(marker!.render(width))).toContain('Goal paused after provider API error'); + for (const line of marker!.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + it('attributes model pause and resume markers to the agent', () => { const paused = buildGoalMarker( { kind: 'lifecycle', status: 'paused' } as GoalChange, - darkColors, false, 'model', ); const resumed = buildGoalMarker( { kind: 'lifecycle', status: 'active' } as GoalChange, - darkColors, false, 'model', ); @@ -71,14 +83,14 @@ describe('buildGoalMarker', () => { it('returns null for a completion change (it posts its own message)', () => { expect( - buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), + buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, false), ).toBeNull(); }); }); describe('GoalMarkerComponent', () => { it('hides the reason until expanded, with a ctrl+o hint', () => { - const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', darkColors, darkColors.warning); + const marker = new GoalMarkerComponent('Goal: no progress', 'still spinning', 'warning'); const collapsed = strip(marker.render(80)); expect(collapsed).toContain('Goal: no progress'); expect(collapsed).toContain('(ctrl+o)'); @@ -91,8 +103,20 @@ describe('GoalMarkerComponent', () => { }); it('renders a single line when there is no reason', () => { - const marker = new GoalMarkerComponent('Goal paused', undefined, darkColors, darkColors.textDim); + const marker = new GoalMarkerComponent('Goal paused', undefined, 'textDim'); expect(marker.render(80)).toHaveLength(1); expect(strip(marker.render(80))).not.toContain('(ctrl+o)'); }); }); + +describe('SwarmModeMarkerComponent', () => { + it('keeps marker lines within very narrow widths', () => { + const marker = new SwarmModeMarkerComponent('active'); + + for (const width of [1, 2, 10, 39]) { + for (const line of marker.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index e56bde8c2..ed69d3a85 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -1,5 +1,6 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildGoalReportLines, @@ -44,7 +45,7 @@ function goal(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { } function lines(g: GoalSnapshot): string { - return strip(buildGoalReportLines({ colors: darkColors, goal: g })); + return strip(buildGoalReportLines(g)); } describe('buildGoalReportLines', () => { @@ -101,25 +102,33 @@ describe('buildGoalReportLines', () => { describe('GoalSetMessageComponent', () => { it('renders a marker-style lifecycle line without repeating the objective', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); // Leading blank line separates it from the line above. expect(rendered[0]).toBe(''); expect(strip(rendered)).toBe('\n● Goal set'); }); it('renders the marker and label in the primary accent', () => { - const rendered = new GoalSetMessageComponent(darkColors).render(60); + const rendered = new GoalSetMessageComponent().render(60); expect(rendered[1]).toBe( chalk.hex(darkColors.primary).bold(STATUS_BULLET) + chalk.hex(darkColors.primary).bold('Goal set'), ); }); + + it('keeps the lifecycle line within narrow widths', () => { + for (const width of [39, 20, 10, 4]) { + for (const line of new GoalSetMessageComponent().render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); describe('UpcomingGoalAddedMessageComponent', () => { it('renders the upcoming-goal confirmation like the goal-set lifecycle line', () => { - const rendered = new UpcomingGoalAddedMessageComponent(darkColors).render(80); + const rendered = new UpcomingGoalAddedMessageComponent().render(80); expect(strip(rendered)).toBe( '\n● Upcoming goal added. It will start after the current goal is complete.', @@ -131,21 +140,47 @@ describe('UpcomingGoalAddedMessageComponent', () => { ), ); }); + + it('wraps the upcoming-goal confirmation within narrow widths', () => { + for (const width of [39, 20, 10, 4]) { + for (const line of new UpcomingGoalAddedMessageComponent().render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); describe('GoalStatusMessageComponent', () => { it('adds a blank line before the status box', () => { - const rendered = new GoalStatusMessageComponent(goal(), darkColors).render(80); + const rendered = new GoalStatusMessageComponent(goal()).render(80); expect(rendered[0]).toBe(''); expect(strip([rendered[1]!])).toContain('╭ Goal · active '); }); + + it('wraps objective blockquotes without clipping them at 80 columns', () => { + const rendered = new GoalStatusMessageComponent( + goal({ objective: 'word '.repeat(30).trim() }), + ).render(80); + + expect(strip(rendered)).not.toContain('...'); + }); + + it('keeps the status box within narrow widths', () => { + const rendered = new GoalStatusMessageComponent(goal({ objective: '管理飞书日历的技能描述 '.repeat(4).trim() })); + + for (const width of [39, 24, 20, 10]) { + for (const line of rendered.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); describe('GoalCompletionMessageComponent', () => { it('renders the completion headline in green and keeps the stats line indented', () => { const message = '✓ Goal complete.\nWorked 1 turn over 2m28s, using 766.9k tokens.'; - const rendered = new GoalCompletionMessageComponent(message, darkColors).render(80); + const rendered = new GoalCompletionMessageComponent(message).render(80); expect(rendered[0]).toBe(''); expect(rendered[1]?.trimEnd()).toBe( diff --git a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts index b62c6df33..63b716208 100644 --- a/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/mcp-status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildMcpStatusReportLines } from '#/tui/components/messages/mcp-status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function strip(text: string): string { describe('buildMcpStatusReportLines', () => { it('folds a multi-line server error onto one row so the panel box stays intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ghidra', @@ -37,7 +35,6 @@ describe('buildMcpStatusReportLines', () => { it('trims and keeps a single-line error intact', () => { const lines = buildMcpStatusReportLines({ - colors: darkColors, servers: [ { name: 'ida', diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index a7a9f05e3..09f727556 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -1,7 +1,11 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; -import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; -import { darkColors } from '#/tui/theme/colors'; +import { CronMessageComponent } from '#/tui/components/messages/cron-message'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from '#/tui/components/messages/status-message'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -12,7 +16,6 @@ describe('NoticeComponent', () => { const component = new NoticeMessageComponent( 'Plan mode: ON', 'Plan will be created here: /tmp/plans/test-plan.md', - darkColors, ); const lines = component.render(120).map((line) => strip(line)); @@ -21,3 +24,37 @@ describe('NoticeComponent', () => { expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md'); }); }); + +describe('CronMessageComponent', () => { + it('keeps title, detail, and prompt within narrow widths', () => { + const component = new CronMessageComponent('Please investigate the reminder payload and report back.', { + cron: '*/15 * * * *', + jobId: 'job-with-a-very-long-identifier-for-width-testing', + recurring: true, + missedCount: 3, + stale: true, + }); + + for (const width of [39, 20, 10, 4]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('StatusMessageComponent', () => { + it('strips carriage returns so a CRLF line does not render blank', () => { + // A trailing `\r` (e.g. from a CRLF server error page) is zero-width for + // the line wrapper, so padding spaces appended after it would otherwise + // overwrite the visible content. The status component strips `\r`. + const component = new StatusMessageComponent('Error: boom\r\nmore\r', 'error'); + const text = component + .render(120) + .map((line) => strip(line)) + .join('\n'); + expect(text).toContain('Error: boom'); + expect(text).toContain('more'); + expect(text).not.toContain('\r'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts index f59121c4b..bb501c05b 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -4,7 +4,6 @@ import { ShellExecutionComponent, shellExecutionResultRenderer, } from '#/tui/components/messages/shell-execution'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -14,7 +13,6 @@ describe('ShellExecutionComponent', () => { it('renders shell command previews with prompt indentation', () => { const component = new ShellExecutionComponent({ command: 'printf hello\nprintf world', - colors: darkColors, showCommand: true, }); @@ -31,7 +29,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, }); const collapsedOutput = collapsed.render(100).map(strip).join('\n'); @@ -46,7 +43,6 @@ describe('ShellExecutionComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - colors: darkColors, expanded: true, }); @@ -60,7 +56,6 @@ describe('ShellExecutionComponent', () => { const cmd = Array.from({ length: 20 }, (_, i) => `step${String(i + 1)}`).join('\n'); const component = new ShellExecutionComponent({ command: cmd, - colors: darkColors, showCommand: true, commandPreviewLines: undefined, }); @@ -77,7 +72,6 @@ describe('ShellExecutionComponent', () => { output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -92,7 +86,6 @@ describe('ShellExecutionComponent', () => { output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines is_error: false, }, - colors: darkColors, }); const output = component.render(100).map(strip).join('\n'); @@ -108,7 +101,6 @@ describe('ShellExecutionComponent', () => { output: 'x'.repeat(500), is_error: false, }, - colors: darkColors, }); const out = strip(component.render(20).join('\n')); @@ -120,7 +112,7 @@ describe('ShellExecutionComponent', () => { describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('omits the command preview when collapsed', () => { + it('renders only the result and leaves the command to the call preview', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -132,18 +124,21 @@ describe('ShellExecutionComponent', () => { output: 'ok', is_error: false, }, - { expanded: false, colors: darkColors }, + { expanded: false }, ); const rendered = components .flatMap((c) => c.render(100)) .map(strip) .join('\n'); + // Command is owned by ToolCallComponent.buildCallPreview, not the + // renderer — rendering it here too would duplicate it once the result + // lands. expect(rendered).not.toContain('$ echo'); expect(rendered).toContain('ok'); }); - it('reveals the full multi-line command when expanded', () => { + it('still renders only the result when expanded', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -152,19 +147,19 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - { expanded: true, colors: darkColors }, + { expanded: true }, ); const rendered = components .flatMap((c) => c.render(300)) .map(strip) .join('\n'); - expect(rendered).toContain(`$ echo ${'a'.repeat(200)}`); - expect(rendered).toContain('echo done'); - expect(rendered).toContain('ok'); + expect(rendered).not.toContain('$ echo'); + expect(rendered).toContain('line4'); + expect(rendered).toContain('line5'); }); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts new file mode 100644 index 000000000..510da06bd --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ShellRunComponent } from '#/tui/components/messages/shell-run'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellRunComponent hardening', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + // Always clear the 1s timer so it can't keep the test process alive or + // fire requestRender after the test ends. + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('caps the running buffer and never throws on huge streaming output', () => { + const c = create(); + const chunk = 'x'.repeat(50_000); + expect(() => { + for (let i = 0; i < 20; i++) c.append(chunk); + c.render(100); + }).not.toThrow(); + }); + + it('finish switches to the final view and ignores later appends', () => { + const c = create(); + c.finish('final output', '', false); + c.append('should be ignored'); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('final output'); + expect(rendered).not.toContain('should be ignored'); + }); + + it('finishBackgrounded renders the background hint', () => { + const c = create(); + c.finishBackgrounded(); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('Moved to background.'); + }); + + it('append / finish are no-ops after dispose', () => { + const c = create(); + c.dispose(); + expect(() => { + c.append('late'); + c.finish('late', '', false); + c.finishBackgrounded(); + c.render(100); + }).not.toThrow(); + }); + + it('does not throw when the render callback throws', () => { + const c = new ShellRunComponent(() => { + throw new Error('render failed'); + }); + component = c; + expect(() => { + c.append('output'); + c.render(100); + }).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 994fbf525..041860896 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,13 +9,12 @@ function strip(text: string): string { describe('status panel report lines', () => { it('formats runtime status, context, and managed usage without account or AGENTS.md rows', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: 'k2', workDir: '/tmp/project', sessionId: 'ses-1', sessionTitle: 'Implement status', - thinking: true, + thinkingEffort: 'on', permissionMode: 'manual', planMode: false, contextUsage: 0.25, @@ -32,7 +30,7 @@ describe('status panel report lines', () => { }, status: { model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 3000, @@ -54,7 +52,7 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('>_ Kimi Code (v1.2.3)'); - expect(output).toContain('Model Kimi K2 (thinking on)'); + expect(output).toContain('Model Kimi K2 (thinking high)'); expect(output).toContain('Directory /tmp/project'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); @@ -70,15 +68,52 @@ describe('status panel report lines', () => { expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('150.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ - colors: darkColors, version: '1.2.3', model: '', workDir: '/tmp/project', sessionId: '', sessionTitle: null, - thinking: false, + thinkingEffort: 'off', permissionMode: 'manual', planMode: false, contextUsage: 0, diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 64c1b6955..e615d7f5c 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -1,9 +1,8 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,7 +12,7 @@ const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'lin describe('ThinkingComponent', () => { it('shows the live spinner header before thinking content', () => { - const component = new ThinkingComponent('working it out', darkColors, true, 'live'); + const component = new ThinkingComponent('working it out', true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).toContain('⠋ thinking...'); @@ -23,7 +22,7 @@ describe('ThinkingComponent', () => { }); it('keeps live thinking height-limited to the tail', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); const out = strip(component.render(80).join('\n')); expect(out).not.toContain('line1'); @@ -37,7 +36,7 @@ describe('ThinkingComponent', () => { it('animates the live spinner and stops on finalize', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const component = new ThinkingComponent('step', darkColors, true, 'live', { + const component = new ThinkingComponent('step', true, 'live', { requestRender, } as unknown as TUI); @@ -55,7 +54,7 @@ describe('ThinkingComponent', () => { }); it('finalizes in place into a collapsed preview', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); @@ -68,7 +67,7 @@ describe('ThinkingComponent', () => { }); it('expands and collapses after finalization', () => { - const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const component = new ThinkingComponent(longThinking, true, 'live'); component.finalize(); component.setExpanded(true); @@ -81,4 +80,13 @@ describe('ThinkingComponent', () => { expect(collapsed).not.toContain('line7'); expect(collapsed).toContain('ctrl+o to expand'); }); + + it('keeps the finalized truncation footer within the requested render width', () => { + const component = new ThinkingComponent(longThinking, true, 'live'); + component.finalize(); + + for (const line of component.render(37)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(37); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 4d54cea6d..84680e3bb 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1,11 +1,10 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { darkColors } from '#/tui/theme/colors'; -import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -42,7 +41,6 @@ describe('ToolCallComponent', () => { output: 'content', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -51,6 +49,100 @@ describe('ToolCallComponent', () => { expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); }); + describe('detach hint for long-running foreground Bash/Agent', () => { + it('shows the Ctrl+B hint after 10s for a running Bash call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_long', name: 'Bash', args: { command: 'sleep 30' } }, + undefined, + stubTui(30), + ); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + vi.advanceTimersByTime(10_000); + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('shows the hint immediately for a running Agent call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_agent_long', name: 'Agent', args: { description: 'explore' } }, + undefined, + stubTui(30), + ); + + // No timer advancement — Agents advertise Ctrl+B immediately. + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint for non-detachable tools', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_read_long', name: 'Read', args: { path: 'foo.ts' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(15_000); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint when the result lands before 10s', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_short', name: 'Bash', args: { command: 'echo hi' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(5_000); + component.setResult({ tool_call_id: 'call_bash_short', output: 'hi', is_error: false }); + vi.advanceTimersByTime(10_000); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + }); + + it('keeps collapsed tool-call lines within very narrow widths', () => { + const component = new ToolCallComponent( + { + id: 'call_narrow_read', + name: 'Read', + args: { path: 'very/long/path/to/foo.ts' }, + }, + { + tool_call_id: 'call_narrow_read', + output: 'content', + is_error: false, + }, + ); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('keeps collapsed tool results short and expands on demand', () => { const component = new ToolCallComponent( { @@ -63,7 +155,6 @@ describe('ToolCallComponent', () => { output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -81,7 +172,118 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('ctrl+o to expand'); }); - it('hides tool output bodies that start with a <system tag', () => { + it('renders live Bash output while the command is running', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('line1\n'); + component.appendLiveOutput('line2\n'); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Running a command'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + + it('clears live Bash output when the final result arrives', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live_done', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('streamed-only\n'); + component.setResult({ + tool_call_id: 'call_shell_live_done', + output: 'final-only\n', + is_error: false, + }); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('final-only'); + expect(out).not.toContain('streamed-only'); + }); + + describe('Bash command preview', () => { + const longCommand = Array.from({ length: 15 }, (_, i) => `echo step${String(i + 1)}`).join( + '\n', + ); + + it('shows the truncated command while running and reveals the rest when expanded', () => { + const component = new ToolCallComponent( + { id: 'call_bash_running', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('Running a command'); + expect(collapsed).toContain('echo step1'); + expect(collapsed).toContain('echo step10'); + expect(collapsed).not.toContain('echo step11'); + + component.setExpanded(true); + + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview after the result lands to avoid a height collapse', () => { + const component = new ToolCallComponent( + { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + // Sanity: while running, the in-flight preview shows the command. + expect(strip(component.render(100).join('\n'))).toContain('$ echo step1'); + + component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); + + // Collapsed result view still shows the command preview (capped at + // COMMAND_PREVIEW_LINES) so a multi-line command with short output does + // not collapse the card. The command is owned by buildCallPreview, so it + // must appear exactly once — the result renderer no longer renders it. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ echo step1'); + expect(out).toContain('echo step10'); + expect(out).not.toContain('echo step11'); + expect(out).toContain('done'); + expect(out.split('$ echo step1').length - 1).toBe(1); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview when the command produces no output', () => { + const component = new ToolCallComponent( + { id: 'call_bash_empty', name: 'Bash', args: { command: 'mkdir -p a/b/c\necho done' } }, + { tool_call_id: 'call_bash_empty', output: '', is_error: false }, + ); + + // buildContent early-returns on empty output, but the command preview + // (owned by buildCallPreview) must still render so the card does not + // collapse to just the header. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ mkdir -p a/b/c'); + expect(out).toContain('echo done'); + }); + }); + + it('hides tool output bodies that start with a <system-reminder tag', () => { const reminderOutput = '<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>'; const component = new ToolCallComponent( @@ -95,11 +297,10 @@ describe('ToolCallComponent', () => { output: reminderOutput, is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).toContain(`${STATUS_BULLET}Ran a command`); expect(collapsed).not.toContain('system-reminder'); expect(collapsed).not.toContain('task tools'); @@ -109,7 +310,7 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('task tools'); }); - it('hides <system-prefixed output even when the tool result is an error', () => { + it('hides <system-reminder-prefixed output even when the tool result is an error', () => { const component = new ToolCallComponent( { id: 'call_hidden_err', @@ -121,7 +322,6 @@ describe('ToolCallComponent', () => { output: '<system-reminder>do not show</system-reminder>', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -129,6 +329,29 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); + it('renders output that merely starts with a literal <system> tag', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with the literal tag — + // a file that contains it, an MCP tool's text — must stay visible. + const component = new ToolCallComponent( + { + id: 'call_literal', + name: 'Bash', + args: { command: 'cat notes.txt' }, + }, + { + tool_call_id: 'call_literal', + output: '<system>literal text from a user file</system>\nsecond line', + is_error: false, + }, + ); + + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('second line'); + }); + it('renders AgentSwarm results as a one-line summary without raw XML', () => { const output = [ '<agent_swarm_result>', @@ -152,7 +375,6 @@ describe('ToolCallComponent', () => { output, is_error: false, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -175,7 +397,6 @@ describe('ToolCallComponent', () => { output: 'provider request failed', is_error: true, }, - darkColors, ); const out = strip(component.render(120).join('\n')); @@ -196,7 +417,6 @@ describe('ToolCallComponent', () => { output: 'first line\n<system-reminder>nope</system-reminder>', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -218,12 +438,11 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# File Plan\n\n1. Do the focused fix.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('Current plan'); - expect(out).toContain('# File Plan'); + expect(out).toContain('File Plan'); expect(out).toContain('1. Do the focused fix.'); expect(out).not.toContain('Plan saved to: /tmp/plan.md'); }); @@ -236,9 +455,7 @@ describe('ToolCallComponent', () => { args: {}, }, undefined, - darkColors, undefined, - createMarkdownTheme(darkColors), ); // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. @@ -265,9 +482,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -276,6 +491,24 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('more lines'); }); + it('plan preview controls are no-ops for non-ExitPlanMode tool calls', () => { + const component = new ToolCallComponent( + { + id: 'call_bash_plan', + name: 'Bash', + args: { command: 'echo hi' }, + }, + undefined, + undefined, + ); + + component.setPlanInfo({ plan: 'should be ignored', path: '/etc/hosts' }); + + const out = strip(component.render(100).join('\n')); + expect(out).not.toContain('should be ignored'); + expect(out).not.toContain('plan:'); + }); + it('ctrl+o does not affect the full plan preview', () => { const longPlan = `# P\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; const component = new ToolCallComponent( @@ -285,9 +518,7 @@ describe('ToolCallComponent', () => { args: { plan: longPlan }, }, undefined, - darkColors, stubTui(24), - createMarkdownTheme(darkColors), ); component.setExpanded(true); const out = strip(component.render(100).join('\n')); @@ -310,7 +541,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# Plan body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -334,7 +564,6 @@ describe('ToolCallComponent', () => { '## Approved Plan:\n# body', is_error: false, }, - darkColors, ); const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; @@ -353,9 +582,7 @@ describe('ToolCallComponent', () => { output: 'User rejected the plan. Feedback:\n\nplease rethink step 2', is_error: false, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -376,9 +603,7 @@ describe('ToolCallComponent', () => { output: 'Plan rejected by user. Plan mode remains active.', is_error: true, }, - darkColors, undefined, - createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); @@ -407,7 +632,6 @@ describe('ToolCallComponent', () => { 'Do NOT edit files other than the plan file while plan mode is active.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -429,7 +653,6 @@ describe('ToolCallComponent', () => { output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', is_error: true, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -452,7 +675,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -478,7 +700,6 @@ describe('ToolCallComponent', () => { ].join('\n'), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -524,7 +745,6 @@ describe('ToolCallComponent', () => { }), is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -546,7 +766,6 @@ describe('ToolCallComponent', () => { output: 'Goal budget set: 10 turns.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -571,7 +790,6 @@ describe('ToolCallComponent', () => { output: 'Goal budget set: 10 turns.', is_error: false, }, - darkColors, ); const out = component.render(100).join('\n'); @@ -594,7 +812,6 @@ describe('ToolCallComponent', () => { output: 'Goal marked blocked.', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -621,7 +838,6 @@ describe('ToolCallComponent', () => { output: `Goal marked ${status}.`, is_error: false, }, - darkColors, ); const out = component.render(100).join('\n'); @@ -645,7 +861,6 @@ describe('ToolCallComponent', () => { output: '1\tfoo\n2\tbar\n3\tbaz', is_error: false, }, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -663,7 +878,6 @@ describe('ToolCallComponent', () => { args: { path: longPath }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -684,16 +898,16 @@ describe('ToolCallComponent', () => { output: '1\tcontent', is_error: false, }, - darkColors, - undefined, undefined, '/tmp/proj-a', ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Read (apps/kimi-code/src/main.ts)'); + const expectedReadPath = + process.platform === 'win32' ? 'apps\\kimi-code\\src\\main.ts' : 'apps/kimi-code/src/main.ts'; + expect(out).toContain(`Used Read (${expectedReadPath})`); expect(out).not.toContain('/tmp/proj-a/apps'); - expect(component.getReadSnapshot().filePath).toBe('apps/kimi-code/src/main.ts'); + expect(component.getReadSnapshot().filePath).toBe(expectedReadPath); }); it('keeps Read paths outside the active workspace absolute', () => { @@ -704,8 +918,6 @@ describe('ToolCallComponent', () => { args: { path: '/tmp/proj-ab/src/main.ts' }, }, undefined, - darkColors, - undefined, undefined, '/tmp/proj-a', ); @@ -723,7 +935,6 @@ describe('ToolCallComponent', () => { args: { path: 'foo.ts' }, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -741,7 +952,6 @@ describe('ToolCallComponent', () => { args: { description: 'explore project xxx' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ @@ -767,14 +977,15 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s'); expect(out).toContain('Using Read (apps/kimi-code/src/tui/utils/background-agent-status.ts)'); + // Thinking and text are mutually exclusive in the active window: the most + // recently streamed (text) wins, so thinking is hidden entirely. expect(out).not.toContain('think1'); - expect(out).toContain('think2'); - expect(out).toContain('think3'); - expect(out).toContain('◌ think2'); + expect(out).not.toContain('think2'); + expect(out).not.toContain('think3'); expect(out).not.toContain('answer1'); - expect(out).not.toContain('answer2'); + expect(out).toContain('answer2'); expect(out).toContain('answer3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); vi.setSystemTime(22_000); component.onSubagentCompleted({ resultSummary: 'summary fallback' }); @@ -788,13 +999,57 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s'); expect(out).not.toContain('think3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); expect(out).not.toContain('Used Agent'); expect(out).not.toContain('parent duplicate result'); expect(out).not.toContain('summary fallback'); }); - it('keeps the single subagent tool area to the latest four activities', () => { + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_detach', + name: 'Agent', + args: { description: 'long task' }, + }, + undefined, + stubTui(30), + ); + component.onSubagentSpawned({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + component.onSubagentStarted({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + + // Sanity: running before detach. + expect(strip(component.render(120).join('\n'))).toContain('Running'); + + component.markBackgrounded(); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + // The spawn-success ToolResult landing must NOT flip the card to Completed. + component.setResult({ + tool_call_id: 'call_agent_detach', + output: 'agent_id: sub_detach_1\nactual_subagent_type: explore\n', + is_error: false, + }); + out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + component.dispose(); + }); + + it('summarizes subagent tools as a count plus the current tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -804,7 +1059,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -825,16 +1079,17 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s'); - expect(out).not.toContain('file1.ts'); - expect(out).toContain('Used Read (file2.ts)'); - expect(out).toContain('Used Read (file3.ts)'); - expect(out).toContain('Used Read (file4.ts)'); - expect(out).not.toContain('… Using Grep (auth)'); - expect(out).toContain('• Using Grep (auth)'); + // Only the current (most recent ongoing) tool appears in the summary line. expect(out).toContain('Using Grep (auth)'); + // No per-tool activity rows are rendered. + expect(out).not.toContain('file1.ts'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + expect(out).not.toContain('Used Read'); }); - it('keeps the single subagent tool window stable when older tools update', () => { + it('keeps the subagent tool summary pinned to the most recent tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -844,7 +1099,6 @@ describe('ToolCallComponent', () => { args: { description: 'inspect tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_tools', @@ -871,14 +1125,16 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(120).join('\n')); + // The updated/finished older tool must not surface in the summary. expect(out).not.toContain('file1-updated.ts'); - expect(out).toContain('Using Read (file2.ts)'); - expect(out).toContain('Using Read (file3.ts)'); - expect(out).toContain('Using Read (file4.ts)'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + // Only the most recent ongoing tool is shown. expect(out).toContain('Using Read (file5.ts)'); }); - it('wraps single subagent thinking and output with hanging indentation', () => { + it('wraps the single subagent active window with a hanging gutter', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -888,31 +1144,23 @@ describe('ToolCallComponent', () => { args: { description: 'inspect wrapping' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_wrapped', agentName: 'explore', runInBackground: false, }); - component.appendSubagentText( - 'thinking words that should wrap with a clean hanging indent', - 'thinking', - ); component.appendSubagentText( 'output words that should also wrap with a clean hanging indent', 'text', ); - const lines = strip(component.render(34).join('\n')).split('\n'); - // Thinking is scrolled to its last two display rows, so the head of the - // wrapped paragraph drops and the ◌ marker hangs on the first kept row. - expect(lines.some((l) => l.includes('◌ wrap with a clean hanging'))).toBe(true); - expect(lines.join('\n')).not.toContain('thinking words that should'); - expect(lines).toContain(' indent '); - // Output keeps its full hanging-indent wrap (unchanged behavior). - expect(lines).toContain(' └ output words that should also '); - expect(lines).toContain(' wrap with a clean hanging '); + const joined = strip(component.render(34).join('\n')); + // The two-row window drops the head of the wrapped paragraph. + expect(joined).not.toContain('output words that should'); + // Every kept row carries the `│` gutter as a hanging indent. + expect(joined).toContain('│ wrap with a clean hanging'); + expect(joined).toContain('│ indent'); }); it('scrolls single subagent thinking to the last two display rows', () => { @@ -925,7 +1173,6 @@ describe('ToolCallComponent', () => { args: { description: 'long think' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_scroll', @@ -944,7 +1191,7 @@ describe('ToolCallComponent', () => { expect(lines.join('\n')).not.toContain('seg00'); }); - it('shows and truncates a single subagent Bash tool output', () => { + it('shows a two-row tail of an ongoing subagent Bash output', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -954,7 +1201,6 @@ describe('ToolCallComponent', () => { args: { description: 'run bash' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_bash', @@ -967,25 +1213,25 @@ describe('ToolCallComponent', () => { args: { command: 'ls -la' }, }); const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_bash:cmd', output, is_error: false }); + component.appendSubToolLiveOutput('sub_bash:cmd', output); let out = strip(component.render(120).join('\n')); - expect(out).toContain('Used Bash (ls -la)'); - expect(out).toContain('bash-line-0'); - expect(out).toContain('bash-line-2'); - expect(out).not.toContain('bash-line-3'); - expect(out).toContain('... (7 more lines)'); - // Subagent output is fixed-truncated: no ctrl+o promise. + expect(out).toContain('Using Bash (ls -la)'); + // The active window keeps only the last two rows of live output. + expect(out).toContain('bash-line-8'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); + // No ctrl+o promise for the subagent window. expect(out).not.toContain('ctrl+o'); - // The global ctrl+o expand toggle must NOT expand subagent output. + // The global ctrl+o expand toggle must NOT expand the window. component.setExpanded(true); out = strip(component.render(120).join('\n')); - expect(out).not.toContain('bash-line-9'); - expect(out).toContain('... (7 more lines)'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); }); - it('truncates unknown subagent tool output but leaves recognized tools as rows', () => { + it('shows live output for generic subagent tools but not for recognized ones', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -995,13 +1241,13 @@ describe('ToolCallComponent', () => { args: { description: 'mixed tools' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_mixed', agentName: 'explore', runInBackground: false, }); + // A finished recognized tool: its output body never reaches the window. component.appendSubToolCall({ id: 'sub_mixed:read', name: 'Read', @@ -1012,23 +1258,22 @@ describe('ToolCallComponent', () => { output: 'recognized-read-body\nhidden-read-line', is_error: false, }); + // An ongoing generic (MCP) tool: its live output is the active stream. component.appendSubToolCall({ id: 'sub_mixed:mcp', name: 'mcp__server__do', args: {}, }); const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_mixed:mcp', output: mcpOut, is_error: false }); + component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut); const out = strip(component.render(120).join('\n')); - // Recognized tool: activity row only, no output body. - expect(out).toContain('Used Read (foo.ts)'); + // Recognized tool output never appears. expect(out).not.toContain('recognized-read-body'); - // Unknown/MCP tool: truncated output body, no ctrl+o promise. - expect(out).toContain('mcp-line-0'); - expect(out).toContain('mcp-line-2'); - expect(out).not.toContain('mcp-line-3'); - expect(out).toContain('... (2 more lines)'); + // Generic tool output shows as the two-row active window tail. + expect(out).toContain('mcp-line-3'); + expect(out).toContain('mcp-line-4'); + expect(out).not.toContain('mcp-line-2'); expect(out).not.toContain('ctrl+o'); }); @@ -1042,7 +1287,6 @@ describe('ToolCallComponent', () => { args: { description: 'check failure' }, }, undefined, - darkColors, ); component.onSubagentSpawned({ agentId: 'sub_failed', @@ -1055,11 +1299,40 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s'); - expect(out).toContain('└ subagent exceeded max_steps'); + expect(out).toContain('│ subagent exceeded max_steps'); expect(out).not.toContain('Using Agent'); expect(out).not.toContain('Used Agent'); }); + it('keeps the same card height between running and done', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_height', + name: 'Agent', + args: { description: 'height stable' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_height', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ id: 'sub_height:read', name: 'Read', args: { path: 'a.ts' } }); + component.appendSubagentText('short answer', 'text'); + + const runningLines = strip(component.render(120).join('\n')).split('\n').length; + + component.onSubagentCompleted({ resultSummary: 'short answer' }); + component.setResult({ tool_call_id: 'call_agent_height', output: 'done', is_error: false }); + + const doneLines = strip(component.render(120).join('\n')).split('\n').length; + + expect(doneLines).toBe(runningLines); + }); + describe('background agent terminal state vs spawn-success ToolResult', () => { // The Agent tool returns a "task spawned" result the moment a // run_in_background=true call lands. That result is not an error and its @@ -1090,7 +1363,6 @@ describe('ToolCallComponent', () => { }, }, spawnSuccessResult, - darkColors, ); component.onSubagentSpawned({ agentId: 'agent-0', @@ -1155,7 +1427,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent A', run_in_background: true }, }, undefined, - darkColors, ); component.setBackgroundTaskTerminalStatus('lost'); // Now the spawn-success result lands. @@ -1206,7 +1477,6 @@ describe('ToolCallComponent', () => { args: { description: 'background agent 1', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); // No spawn metadata was wired in — exactly the resume / backgrounded // case we are guarding against. @@ -1224,7 +1494,6 @@ describe('ToolCallComponent', () => { args: { description: 'X', run_in_background: true }, }, spawnSuccessResult, - darkColors, ); component.setSubagentMeta('agent-explicit', 'coder'); expect(component.getSubagentAgentId()).toBe('agent-explicit'); @@ -1242,7 +1511,6 @@ describe('ToolCallComponent', () => { output: 'agent_id: agent-fake\nstatus: running', is_error: false, }, - darkColors, ); expect(component.getSubagentAgentId()).toBeUndefined(); }); @@ -1293,7 +1561,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1322,7 +1589,6 @@ describe('ToolCallComponent', () => { truncated: true, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1361,7 +1627,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); @@ -1395,7 +1660,6 @@ describe('ToolCallComponent', () => { // No streamingArguments → finalized args; no result yet. }, undefined, - darkColors, ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); @@ -1417,7 +1681,6 @@ describe('ToolCallComponent', () => { streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('line25'); @@ -1443,7 +1706,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{', }, undefined, - darkColors, ); const before = strip(component.render(100).join('\n')); expect(before).toContain('Using Write'); @@ -1470,7 +1732,6 @@ describe('ToolCallComponent', () => { streamingArguments: '{"file_path":"foo.ts","content":"a\\nb', }, undefined, - darkColors, ); // While streaming, body is rendered live from streamingArguments. expect(strip(component.render(100).join('\n'))).toMatch(/^\s*1\s+a\s*$/m); @@ -1497,7 +1758,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: Date.now(), }, undefined, - darkColors, ); expect(strip(component.render(100).join('\n'))).toContain('Preparing changes'); expect(strip(component.render(100).join('\n'))).not.toMatch(/^\s*\d+\s+[+-]\s/m); @@ -1526,7 +1786,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); @@ -1553,7 +1812,6 @@ describe('ToolCallComponent', () => { streamingStartedAtMs: 0, }, undefined, - darkColors, ui as never, ); ui.requestRender.mockClear(); @@ -1576,7 +1834,6 @@ describe('ToolCallComponent', () => { output: 'Wrote big.txt', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); @@ -1607,7 +1864,6 @@ describe('ToolCallComponent', () => { output: 'Wrote demo.abcxyz', is_error: false, }, - darkColors, ); const collapsed = strip(component.render(100).join('\n')); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd6..691f1d88a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -38,7 +38,6 @@ function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string { { type: 'text', text: `<image path="${path}">` }, { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, { type: 'text', text: '</image>' }, - { type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` }, ]); } @@ -58,7 +57,6 @@ describe('parseReadMediaOutput', () => { expect(m?.path).toBe('/tmp/a.png'); expect(m?.mimeType).toBe('image/png'); expect(m?.bytes).toBeGreaterThan(0); - expect(m?.originalSize).toBe('1x1px'); }); it('extracts video kind and mime', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 7dcd55bed..6570aac46 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts index 5b54fe6a6..4c90c0392 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -1,7 +1,8 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; -import { darkColors } from '#/tui/theme/colors'; + function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); @@ -12,7 +13,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent(['a', 'b', 'c', 'd', 'e'].join('\n'), { expanded: false, isError: false, - colors: darkColors, maxLines: 2, indent: 6, }); @@ -27,7 +27,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('x\ny\nz', { expanded: false, isError: false, - colors: darkColors, maxLines: 1, }); @@ -40,7 +39,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('a\nb\nc\nd', { expanded: false, isError: false, - colors: darkColors, maxLines: 2, indent: 4, expandHint: false, @@ -54,7 +52,6 @@ describe('TruncatedOutputComponent', () => { const component = new TruncatedOutputComponent('a\nb\nc\nd', { expanded: true, isError: false, - colors: darkColors, maxLines: 2, indent: 4, }); @@ -63,4 +60,31 @@ describe('TruncatedOutputComponent', () => { expect(out).toContain('d'); expect(out).not.toContain('more lines, ctrl+o'); }); + + it('keeps the truncation footer within the requested render width', () => { + const output = Array.from({ length: 20 }, (_, i) => `line ${String(i)}`).join('\n'); + const component = new TruncatedOutputComponent(output, { + expanded: false, + isError: false, + maxLines: 3, + indent: 2, + }); + + for (const line of component.render(37)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(37); + } + }); + + it('renders output verbatim, including literal <system> text in file content', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so the renderer must not eat user data that + // merely contains the literal tag. + const component = new TruncatedOutputComponent( + '<system>literal text from a user file</system>\n<image path="/tmp/x.png">', + { expanded: true, isError: false }, + ); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('<image path="/tmp/x.png">'); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 5e3883ca9..cf2598f82 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,8 +1,12 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -11,7 +15,6 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { const lines = buildUsageReportLines({ - colors: darkColors, sessionUsage: { byModel: { kimi: { @@ -21,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -45,8 +48,144 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets tomorrow'); }); + it('formats extra usage with a monthly limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); + }); + + it('formats extra usage without a monthly limit and omits the progress bar', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 18208, + totalCents: 40000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 21792, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); + }); + + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { summary: null, limits: [], extraUsage }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, + }, + }).map(strip); + + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); + }); + it('wraps preformatted usage lines in a bordered panel', () => { - const component = new UsagePanelComponent(['Session usage'], darkColors.primary); + const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); expect(output[0]).toContain(' Usage '); @@ -55,7 +194,7 @@ describe('UsagePanelComponent', () => { it('truncates lines wider than the terminal so the panel never overflows', () => { const longLine = 'error: ' + 'x'.repeat(200); - const component = new UsagePanelComponent([longLine], darkColors.primary); + const component = new UsagePanelComponent(() => [longLine], 'primary'); const width = 60; const output = component.render(width); @@ -64,4 +203,30 @@ describe('UsagePanelComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it('keeps the bordered panel within narrow terminal widths', () => { + const component = new UsagePanelComponent(() => ['Session usage', ' kimi input 2.0k'], 'primary'); + + for (const width of [39, 24, 20, 10, 4, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('rebuilds its body from the active palette on invalidate', () => { + // Emit the resolved palette value as visible text so the assertion holds + // regardless of chalk's colour level in the test environment. + const component = new UsagePanelComponent(() => [`text=${currentTheme.color('text')}`], 'primary'); + const bodyOf = (): string => { + const line = component.render(80).map(strip).find((l) => l.includes('text=')); + if (line === undefined) throw new Error('body line not found'); + return line; + }; + + expect(bodyOf()).toContain(darkColors.text); + currentTheme.setPalette(lightColors); + component.invalidate(); + expect(bodyOf()).toContain(lightColors.text); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index ab1d5752b..e6a10a05c 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -1,17 +1,23 @@ -import { describe, expect, it } from 'vitest'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; -import { darkColors } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } describe('UserMessageComponent', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + it('renders video placeholders as plain text, not inline image escapes', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', - darkColors, [], ); @@ -21,4 +27,81 @@ describe('UserMessageComponent', () => { expect(out).not.toContain('\u001B_G'); expect(out).not.toContain('\u001B]1337;File='); }); + + it('keeps user lines within very narrow widths', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const component = new UserMessageComponent('please inspect the attached output', []); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('does not truncate inline image escape sequences', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + + // Minimal 2000x1302 PNG bytes so the inline Kitty sequence is long enough + // to exceed a typical terminal width if treated as visible text. + const pngSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdrLength = new Uint8Array([0x00, 0x00, 0x00, 0x0d]); + const ihdrType = new Uint8Array([0x49, 0x48, 0x44, 0x52]); + const widthBytes = new Uint8Array([ + (2000 >> 24) & 0xff, + (2000 >> 16) & 0xff, + (2000 >> 8) & 0xff, + 2000 & 0xff, + ]); + const heightBytes = new Uint8Array([ + (1302 >> 24) & 0xff, + (1302 >> 16) & 0xff, + (1302 >> 8) & 0xff, + 1302 & 0xff, + ]); + const rest = new Uint8Array([0x08, 0x02, 0x00, 0x00, 0x00]); + const bytes = new Uint8Array([ + ...pngSignature, + ...ihdrLength, + ...ihdrType, + ...widthBytes, + ...heightBytes, + ...rest, + ]); + + const attachment: ImageAttachment = { + id: 1, + kind: 'image', + bytes, + mime: 'image/png', + width: 2000, + height: 1302, + placeholder: '[image #1 (2000×1302)]', + }; + + const component = new UserMessageComponent('', [attachment]); + const lines = component.render(80); + + const imageLine = lines.find((l) => l.includes('\u001B_G')); + expect(imageLine).toBeDefined(); + expect(imageLine).not.toContain('\u001B[0m'); + expect(imageLine).not.toContain('…'); + expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator + }); + + it('omits the sparkles bullet when an empty bullet is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const withBullet = stripAnsi(new UserMessageComponent('hello', []).render(80).join('\n')); + expect(withBullet).toContain('✨'); + expect(withBullet).toContain('hello'); + + const lines = new UserMessageComponent('$ ls', [], '').render(80).map(stripAnsi); + const contentLine = lines.find((l) => l.includes('$ ls')); + expect(contentLine).toBeDefined(); + expect(stripAnsi(lines.join('\n'))).not.toContain('✨'); + // The `$` sits at the leading column where the bullet used to be. + expect(contentLine?.startsWith('$ ls')).toBe(true); + }); }); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index bb846c7eb..101cd1911 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -13,10 +12,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -35,7 +35,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); const [line1] = footer.render(120); expect(line1).toBeDefined(); expect(strip(line1!)).not.toMatch(/tasks? running/); @@ -43,7 +43,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the task badge alone when only bash tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -51,7 +51,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders the agent badge alone when only agent tasks are running', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 agent running\]/); @@ -59,7 +59,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('renders both badges side by side when both are non-zero', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); @@ -69,7 +69,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('pluralizes correctly across both badges', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); @@ -77,7 +77,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('updates badges live via setBackgroundCounts', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); @@ -87,7 +87,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('clamps negative counts to 0', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); const out = strip(footer.render(120)[0]!); expect(out).not.toMatch(/tasks? running/); @@ -95,7 +95,7 @@ describe('FooterComponent — background task / agent badges', () => { }); it('drops the badges when terminal is too narrow to fit them', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); // Extremely narrow width: footer primary content fills the line, so leftLine wins. const [line1] = footer.render(20); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index db34ee895..2eebee5e3 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -22,10 +22,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -44,7 +45,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { describe('FooterComponent — context NaN resilience', () => { it('NaN usage → renders 0.0% (never literal "NaN%")', () => { - const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); expect(out).toMatch(/context: 0\.0%/); @@ -53,7 +54,6 @@ describe('FooterComponent — context NaN resilience', () => { it('undefined-ish (coerced) usage → renders 0.0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), - darkColors, ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); @@ -61,13 +61,13 @@ describe('FooterComponent — context NaN resilience', () => { }); it('clamps ratios above 1.0 → renders 100.0%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 1.5 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); expect(out).toMatch(/context: 100\.0%/); }); it('ratio 0.427 → renders 42.7%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 0.427 }), darkColors); + const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); expect(out).toMatch(/context: 42\.7%/); }); @@ -75,7 +75,6 @@ describe('FooterComponent — context NaN resilience', () => { it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { const fc = new FooterComponent( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), - darkColors, ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); @@ -85,7 +84,7 @@ describe('FooterComponent — context NaN resilience', () => { }); it('setState updates visible model and context values', () => { - const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 }), darkColors); + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); @@ -96,15 +95,15 @@ describe('FooterComponent — context NaN resilience', () => { }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true }), darkColors); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false }), darkColors); + const on = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'on' })); + const off = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'off' })); expect(strip(on.render(120)[0]!)).toContain('thinking'); expect(strip(off.render(120)[0]!)).not.toContain('thinking'); }); it('renders transient hints on the context line', () => { - const footer = new FooterComponent(baseState(), darkColors); + const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); @@ -134,7 +133,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const primaryIndex = out.indexOf(hexToSgr(darkColors.primary)); - const statusIndex = out.indexOf(hexToSgr(darkColors.status)); + const statusIndex = out.indexOf(hexToSgr(darkColors.textDim)); const badgeIndex = out.indexOf('[PR#6]'); expect(statusIndex).toBeGreaterThanOrEqual(0); expect(primaryIndex).toBeGreaterThanOrEqual(0); diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 59ec24331..982be0657 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { darkColors } from '#/tui/theme/colors'; import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; @@ -14,10 +13,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -57,12 +57,12 @@ describe('FooterComponent — goal badge', () => { }); it('omits the badge when there is no goal', () => { - const footer = new FooterComponent(baseState({ goal: null }), darkColors); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + const footer = new FooterComponent(baseState({ goal: null })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { - const footer = new FooterComponent(baseState({ goal: goal() }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal() })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('active'); @@ -78,7 +78,6 @@ describe('FooterComponent — goal badge', () => { const footer = new FooterComponent( baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('0s'); @@ -90,7 +89,7 @@ describe('FooterComponent — goal badge', () => { vi.useFakeTimers(); const onRefresh = vi.fn(); - new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), darkColors, onRefresh); + new FooterComponent(baseState({ goal: goal({ wallClockMs: 0 }) }), onRefresh); vi.advanceTimersByTime(1_000); expect(onRefresh).toHaveBeenCalledTimes(1); @@ -99,30 +98,29 @@ describe('FooterComponent — goal badge', () => { it('shows used/limit turns only when a turn budget is set', () => { const footer = new FooterComponent( baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial<GoalSnapshot>) }), - darkColors, ); expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); }); it('shows a paused badge', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); expect(strip(footer.render(160)[0]!)).toContain('paused'); }); it('shows a blocked badge (resumable, still present)', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('blocked'); }); it('hides the badge for a completed goal', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) }), darkColors); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('singularizes a single turn', () => { - const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) }), darkColors); + const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); const out = strip(footer.render(160)[0]!); expect(out).toContain('1 turn'); expect(out).not.toContain('1 turns'); diff --git a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts index d9692366a..52f94430b 100644 --- a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { KimiSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; -import { darkColors } from '#/tui/theme/colors'; function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { return { @@ -20,7 +19,6 @@ describe('HelpPanelComponent', () => { it('renders keyboard shortcuts + slash commands sections', () => { const panel = new HelpPanelComponent({ commands: [cmd('exit', 'Exit', ['quit', 'q'])], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -42,7 +40,6 @@ describe('HelpPanelComponent', () => { cmd('alpha', 'A'), cmd('mcp-config', 'M'), ], - colors: darkColors, onClose: () => {}, }); const out = strip(panel.render(80).join('\n')); @@ -60,7 +57,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('\u001B'); // Esc @@ -71,7 +67,6 @@ describe('HelpPanelComponent', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ commands: [], - colors: darkColors, onClose, }); panel.handleInput('q'); @@ -83,7 +78,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, `Desc ${String(i)}`)); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); @@ -95,7 +89,6 @@ describe('HelpPanelComponent', () => { const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); const panel = new HelpPanelComponent({ commands: many, - colors: darkColors, onClose: () => {}, maxVisible: 6, }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 6813a7137..1d970bb80 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -1,3 +1,6 @@ +import { pathToFileURL } from 'node:url'; + +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; @@ -15,7 +18,7 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } -const theme = createMarkdownTheme(darkColors); +const theme = createMarkdownTheme(); describe('PlanBoxComponent', () => { it('falls back to bare " plan " title when no path is provided', () => { @@ -69,7 +72,7 @@ describe('PlanBoxComponent', () => { it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); const top = box.render(60)[0]!; - expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); + expect(top).toContain(`${ESC}]8;;${pathToFileURL('/tmp/plan.md').href}${BEL}plan.md${ESC}]8;;${BEL}`); // After stripping OSC + CSI, visible width must respect the requested render width. expect(strip(top).length).toBeLessThanOrEqual(60); }); @@ -77,7 +80,7 @@ describe('PlanBoxComponent', () => { it('skips the hyperlink for non-absolute paths but still shows the basename', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, 'relative/plan.md'); const top = box.render(60)[0]!; - expect(top).not.toContain(`${ESC}]8;`); + expect(top).not.toContain(`${ESC}];`); expect(strip(top)).toContain(' plan: plan.md '); }); @@ -89,6 +92,21 @@ describe('PlanBoxComponent', () => { expect(top).not.toContain('plan:'); }); + it('keeps every line within narrow widths', () => { + const box = new PlanBoxComponent( + '# Hello\n\n' + 'step with a fairly long description '.repeat(4), + theme, + darkColors.success, + '/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md', + ); + + for (const width of [39, 14, 10, 8, 4, 1]) { + for (const line of box.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + it('renders all plan lines without a truncation footer', () => { const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); const box = new PlanBoxComponent(plan, theme, darkColors.success); @@ -97,5 +115,4 @@ describe('PlanBoxComponent', () => { expect(out).toContain('step 30'); expect(out).not.toContain('more lines'); }); - }); diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index 2382c0a60..ee6dcbb1c 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; -import { darkColors } from '#/tui/theme/colors'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -13,13 +13,13 @@ function strip(text: string): string { describe('TodoPanelComponent', () => { it('returns no lines when empty (so the layout slot collapses)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); expect(panel.render(80)).toEqual([]); expect(panel.isEmpty()).toBe(true); }); it('renders a Todo header + one row per entry', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'Investigate parser', status: 'done' }, { title: 'Add tests', status: 'in_progress' }, @@ -34,7 +34,7 @@ describe('TodoPanelComponent', () => { }); it('setTodos replaces the list (not appends)', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'old', status: 'pending' }]); panel.setTodos([{ title: 'new', status: 'in_progress' }]); const out = strip(panel.render(80).join('\n')); @@ -43,7 +43,7 @@ describe('TodoPanelComponent', () => { }); it('clear() wipes the list and reverts to empty', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([{ title: 'x', status: 'pending' }]); panel.clear(); expect(panel.isEmpty()).toBe(true); @@ -51,7 +51,7 @@ describe('TodoPanelComponent', () => { }); it('defensive copy: external mutation does not leak into the panel', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); const source: TodoItem[] = [{ title: 'foo', status: 'pending' }]; panel.setTodos(source); source[0] = { title: 'hacked', status: 'done' }; @@ -61,7 +61,7 @@ describe('TodoPanelComponent', () => { }); it('renders all todos and no overflow footer when count <= 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 'a', status: 'done' }, { title: 'b', status: 'in_progress' }, @@ -76,7 +76,7 @@ describe('TodoPanelComponent', () => { }); it('appends "+N more" footer when count > 5', () => { - const panel = new TodoPanelComponent(darkColors); + const panel = new TodoPanelComponent(); panel.setTodos([ { title: 't0', status: 'done' }, { title: 't1', status: 'in_progress' }, @@ -89,6 +89,110 @@ describe('TodoPanelComponent', () => { const out = strip(panel.render(80).join('\n')); expect(out).toMatch(/\+2 more/); }); + + const many = (n: number): TodoItem[] => + Array.from({ length: n }, (_, i) => ({ title: `t${i}`, status: 'pending' as const })); + + it('hasOverflow() is false when count <= 5 and true when count > 5', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(5)); + expect(panel.hasOverflow()).toBe(false); + panel.setTodos(many(6)); + expect(panel.hasOverflow()).toBe(true); + }); + + it('collapsed footer advertises "ctrl+t to expand"', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+2 more/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer shows hidden status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + ...Array.from({ length: 6 }, (_, i) => ({ + title: `ip${i}`, + status: 'in_progress' as const, + })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `p${i}`, status: 'pending' as const })), + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+7 more \(3 done · 1 in progress · 3 pending\)/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer omits zero-count statuses', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+3 more \(3 done\)/); + expect(out).not.toMatch(/0 in progress/); + expect(out).not.toMatch(/0 pending/); + }); + + it('expanded footer does not include status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/all 8 items · ctrl\+t to collapse/); + expect(out).not.toMatch(/\d+ done ·/); + }); + + it('renders every todo with a collapse hint when expanded', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/t0/); + expect(out).toMatch(/t6/); + expect(out).not.toMatch(/\+\d+ more/); + expect(out).toMatch(/ctrl\+t to collapse/); + }); + + it('toggleExpanded() flips between collapsed and expanded', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/ctrl\+t to collapse/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + }); + + it('setTodos() keeps the expanded state across list updates', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + panel.setTodos([ + { title: 'u0', status: 'pending' }, + { title: 'u1', status: 'pending' }, + { title: 'u2', status: 'pending' }, + { title: 'u3', status: 'pending' }, + { title: 'u4', status: 'pending' }, + { title: 'u5', status: 'pending' }, + { title: 'u6', status: 'pending' }, + ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/u6/); + expect(out).toMatch(/ctrl\+t to collapse/); + }); + + it('clear() resets the expanded state', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + panel.clear(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + }); }); describe('selectVisibleTodos', () => { @@ -239,4 +343,41 @@ describe('selectVisibleTodos', () => { expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']); expect(hidden).toBe(2); }); + + it('returns hiddenCounts reflecting the hidden items', () => { + const todos: TodoItem[] = [ + ...Array.from({ length: 6 }, (_, i) => T(`ip${i}`, 'in_progress')), + ...Array.from({ length: 3 }, (_, i) => T(`d${i}`, 'done')), + ...Array.from({ length: 3 }, (_, i) => T(`p${i}`, 'pending')), + ]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(7); + expect(hiddenCounts).toEqual({ done: 3, in_progress: 1, pending: 3 }); + }); + + it('returns zero hiddenCounts when count <= 5', () => { + const todos: TodoItem[] = [T('a', 'done'), T('b', 'in_progress'), T('c', 'pending')]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(0); + expect(hiddenCounts).toEqual({ done: 0, in_progress: 0, pending: 0 }); + }); +}); + +describe('formatHiddenCounts', () => { + it('formats all three statuses in done / in progress / pending order', () => { + expect(formatHiddenCounts({ done: 2, in_progress: 1, pending: 3 })).toBe( + '2 done · 1 in progress · 3 pending', + ); + }); + + it('omits zero-count statuses', () => { + expect(formatHiddenCounts({ done: 5, in_progress: 0, pending: 0 })).toBe('5 done'); + expect(formatHiddenCounts({ done: 0, in_progress: 2, pending: 3 })).toBe( + '2 in progress · 3 pending', + ); + }); + + it('returns empty string when all counts are zero', () => { + expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); + }); }); diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 383c43693..76acd438c 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -1,8 +1,31 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +function createMockSpinner(initialText = 'working') { + const spinner = new Text(initialText, 0, 0); + let tip = ''; + let availableWidth = 0; + const update = () => { + const fullText = initialText + tip; + spinner.setText(availableWidth > 0 && visibleWidth(fullText) > availableWidth ? initialText : fullText); + }; + return { + spinner: Object.assign(spinner, { + setTip(value: string) { + tip = value; + update(); + }, + setAvailableWidth(width: number) { + availableWidth = width; + update(); + }, + }) as unknown as import('#/tui/components/chrome/moon-loader').MoonLoader, + getTip: () => tip, + }; +} + describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { const component = new ActivityPaneComponent({ @@ -22,8 +45,53 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it.each(['waiting', 'tool', 'composing'] as const)( + 'renders %s spinner with tip after a spacer', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tip: ctrl+s: steer mid-turn', + ]); + }, + ); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'does not render a tip for %s when none is provided', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); + it('renders nothing for hidden and thinking modes', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'hides the tip for %s when the terminal is too narrow', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + // Width 8 is exactly the width of "working" (no spinner frame in the mock). + expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); }); diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index fbb5e4979..0f9a01177 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { QueuePaneComponent } from '#/tui/components/panes/queue-pane'; -import { darkColors } from '#/tui/theme/colors'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -10,7 +9,6 @@ function stripAnsi(text: string): string { describe('QueuePaneComponent', () => { it('renders queued messages with the steer hint', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: true, @@ -29,7 +27,6 @@ describe('QueuePaneComponent', () => { it('renders compaction hint when waiting for compaction', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: true, isStreaming: false, canSteerImmediately: true, @@ -43,7 +40,6 @@ describe('QueuePaneComponent', () => { it('omits the steer hint when immediate steering is disabled', () => { const component = new QueuePaneComponent({ - colors: darkColors, isCompacting: false, isStreaming: true, canSteerImmediately: false, @@ -55,4 +51,72 @@ describe('QueuePaneComponent', () => { expect(output).toContain('will send after current task'); expect(output).not.toContain('ctrl-s to steer immediately'); }); + + it('truncates long messages to a single line', () => { + const longText = 'a'.repeat(200); + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: longText }], + }); + + const lines = component.render(30); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); + expect(messageLine).not.toContain('a'.repeat(30)); + expect(messageLine.endsWith('…')).toBe(true); + }); + + it('collapses multiline text into a single line', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'line one\nline two\nline three' }], + }); + + const lines = component.render(120); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); + expect(messageLine).toContain('line one line two line three'); + expect(messageLine).not.toContain('\n'); + }); + + it('renders bash queued items with a $ prompt to distinguish them from text', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'ls -la', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('❯ $ ls -la'); + }); + + it('omits the steer hint when every queued item is a bash command', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).not.toContain('ctrl-s to steer immediately'); + expect(output).toContain('will send after current task'); + }); + + it('keeps the steer hint when at least one queued item is steerable', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }, { text: 'focus on tests' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('ctrl-s to steer immediately'); + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 82bef3010..c4c5035cd 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -59,12 +59,22 @@ auto_install = false expect(config).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, }); }); + it('parses disable_paste_burst', () => { + const config = parseTuiConfig(` +theme = "dark" +disable_paste_burst = true +`); + + expect(config.disablePasteBurst).toBe(true); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -73,6 +83,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -104,6 +115,7 @@ command = " " await saveTuiConfig( { theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -113,9 +125,26 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, }); }); + + it('escapes special characters in a custom theme name so the TOML round-trips', async () => { + const theme = 'weird"name\\with-quote'; + await saveTuiConfig( + { + theme, + disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + editorCommand: null, + notifications: DEFAULT_TUI_CONFIG.notifications, + upgrade: DEFAULT_TUI_CONFIG.upgrade, + }, + filePath, + ); + + expect((await loadTuiConfig(filePath)).theme).toBe(theme); + }); }); diff --git a/apps/kimi-code/test/tui/constant/tips.test.ts b/apps/kimi-code/test/tui/constant/tips.test.ts new file mode 100644 index 000000000..de0f69ef3 --- /dev/null +++ b/apps/kimi-code/test/tui/constant/tips.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { ALL_TIPS, WORKING_TIPS } from '#/tui/constant/tips'; + +describe('tips constants', () => { + it('ALL_TIPS is non-empty', () => { + expect(ALL_TIPS.length).toBeGreaterThan(0); + }); + + it('tip texts are unique across ALL_TIPS', () => { + const texts = ALL_TIPS.map((tip) => tip.text); + expect(new Set(texts).size).toBe(texts.length); + }); + + it('every tip has a non-empty text', () => { + for (const tip of ALL_TIPS) { + expect(tip.text.length).toBeGreaterThan(0); + } + }); + + it('every tip has valid optional properties', () => { + for (const tip of ALL_TIPS) { + if (tip.priority !== undefined) { + expect(tip.priority).toBeGreaterThan(0); + } + if (tip.solo !== undefined) { + expect(typeof tip.solo).toBe('boolean'); + } + } + }); + + it('WORKING_TIPS is non-empty', () => { + expect(WORKING_TIPS.length).toBeGreaterThan(0); + }); + + it('every working tip is included in ALL_TIPS', () => { + for (const workingTip of WORKING_TIPS) { + expect(ALL_TIPS.some((tip) => tip.text === workingTip.text)).toBe(true); + } + }); + + it('shared working tips match ALL_TIPS priority and solo values', () => { + for (const workingTip of WORKING_TIPS) { + const allTip = ALL_TIPS.find((tip) => tip.text === workingTip.text); + expect(allTip).toBeDefined(); + expect(allTip?.priority).toBe(workingTip.priority); + expect(allTip?.solo).toBe(workingTip.solo); + } + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts new file mode 100644 index 000000000..2bc4c5b4e --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -0,0 +1,672 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ClipboardImageHintController, + type ClipboardImageHintHost, +} from '#/tui/controllers/clipboard-image-hint'; +import type { FooterComponent } from '#/tui/components/chrome/footer'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '#/tui/utils/terminal-focus'; +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +vi.mock('#/utils/clipboard/clipboard-has-image', () => ({ + clipboardHasImage: vi.fn(async () => false), +})); + +type FakeTUI = TUI & { emitInput(data: string): void }; + +interface FakeFooter { + hint: string | null; + setTransientHint(hint: string | null): void; + getTransientHint(): string | null; +} + +function createFakeFooter(): FooterComponent { + const footer: FakeFooter = { + hint: null, + setTransientHint(hint: string | null): void { + this.hint = hint; + }, + getTransientHint(): string | null { + return this.hint; + }, + }; + return footer as unknown as FooterComponent; +} + +function createFakeTUI(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + listener(data); + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +function createFakeTUIWithConsumingFocusTracker(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + const result = listener(data); + if (result?.consume) return; + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +// Drive the controller through its first clipboard observation with an empty +// clipboard. The first observation only establishes a baseline and never shows +// a hint, leaving the controller armed and ready for the next new image. +async function primeEmptyBaseline(ui: FakeTUI): Promise<void> { + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +// Simulate the user returning to the terminal and let the debounced check fire. +async function focusReturnAndFlush(ui: FakeTUI): Promise<void> { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +describe('ClipboardImageHintController', () => { + let platformSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(clipboardHasImage).mockResolvedValue(false); + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + + afterEach(() => { + platformSpy?.mockRestore(); + vi.useRealTimers(); + }); + + it('does not show a hint for an image already in the clipboard at startup', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Startup baseline observes the image already present; the focus check + // runs too but must stay quiet for that same image. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + // One call is the startup baseline; the second is the focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('shows hint when a new image is copied during the session', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('shows hint for the first image copied after startup when startup baseline was empty', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).toBeNull(); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('does not show hint when model does not support images', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => false, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not repeat the hint for the same lingering image', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline and show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // The same image is still in the clipboard: focusing again must not nag. + vi.mocked(clipboardHasImage).mockClear(); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + controller.stop(); + }); + + it('shows the hint again for a new image after the clipboard is cleared', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline, then show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // Clipboard cleared: the empty check re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows again. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('clears the hint after the display duration', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('cancels a pending debounced check when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + vi.mocked(clipboardHasImage).mockClear(); + + ui.emitInput(TERMINAL_FOCUS_IN); + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).not.toHaveBeenCalled(); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('handles rapid focus churn without duplicate checks or hints', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardHasImage).mockClear(); + + for (let i = 0; i < 5; i++) { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + } + + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + }); + + it('ignores stale clipboard read result when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise((resolve) => setTimeout(() => { resolve(true); }, 1500)), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1500); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('ignores a pending clipboard read result after stop', async () => { + let resolveDeferred: (value: boolean) => void = () => {}; + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise<boolean>((resolve) => { + resolveDeferred = resolve; + }), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + controller.stop(); + resolveDeferred(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + }); + + it('clears a displayed hint when stopped', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + expect(footer.getTransientHint()).toBeNull(); + expect(host.requestRender).toHaveBeenCalled(); + }); + + it('does not clear a hint set by another caller when stopped', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const requestRender = vi.fn(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender, + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // First observation only establishes the baseline and sets no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + const otherHint = 'Other hint'; + footer.setTransientHint(otherHint); + + const requestRenderCalls = requestRender.mock.calls.length; + controller.stop(); + expect(footer.getTransientHint()).toBe(otherHint); + expect(host.requestRender).toHaveBeenCalledTimes(requestRenderCalls); + }); + + it('uses only the latest clipboard read result after focus churn', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish an empty baseline with a normal resolved read first. + await primeEmptyBaseline(ui); + + const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise<boolean> }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise<boolean>((res) => { + resolve = res; + }); + deferreds.push({ resolve, promise }); + return promise; + }); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(2); + + deferreds[0]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + + deferreds[1]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('keeps the existing auto-clear timer when a re-check exits early', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + // Trigger a re-check that exits early because the clipboard is now empty. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + + // The previous hint should still be visible because its auto-clear timer + // was preserved through the re-check. + expect(footer.getTransientHint()).not.toBeNull(); + + // Advance the remaining original display duration and verify it expires. + await vi.advanceTimersByTimeAsync(3000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not clear a matching hint owned by another caller after auto-clear', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + const hintText = footer.getTransientHint(); + expect(hintText).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + // Another caller sets the same hint text the controller previously used. + footer.setTransientHint(hintText); + + controller.stop(); + expect(footer.getTransientHint()).toBe(hintText); + }); + + it('re-establishes the baseline after stop and restart', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Image already present at start: baseline only, no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + controller.start(); + + // After restart the image is still present: baseline again, no hint. + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // Clipboard cleared: re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('observes focus events even when another listener consumes them', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUIWithConsumingFocusTracker(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Register a second listener that consumes focus events, like installTerminalFocusTracking. + const consumedEvents: string[] = []; + ui.addInputListener((data) => { + if (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT) { + consumedEvents.push(data); + return { consume: true }; + } + return undefined; + }); + + // Baseline observation (consumed), then a new image on the next focus. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('shows Alt+V shortcut on Windows', async () => { + platformSpy?.mockRestore(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Alt\+V/); + + controller.stop(); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts new file mode 100644 index 000000000..d565481ca --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -0,0 +1,299 @@ +/** + * Clipboard image paste → attachment store, with ingestion-time compression. + * + * Tests pin: + * - an oversized pasted image is downsampled while building the attachment, + * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual + * submitted image all agree on the compressed size + * - the pre-compression original is persisted and recorded on the + * attachment, so the submitted prompt can announce the compression and + * point the model at the full-fidelity bytes + * - a within-budget paste is stored byte-for-byte (fast path), with no + * original recorded + */ + +import { mkdtemp, readFile, rm, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Jimp } from 'jimp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still +// applies to the editor-keyboard module that pulls in readClipboardMedia. +const { readClipboardMedia } = vi.hoisted(() => ({ readClipboardMedia: vi.fn() })); + +vi.mock('#/utils/clipboard/clipboard-image', async (importActual) => { + const actual = await importActual<typeof import('#/utils/clipboard/clipboard-image')>(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + readonly track: ReturnType<typeof vi.fn>; + pasteImage(): Promise<void>; +} + +function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const store = new ImageAttachmentStore(); + const track = vi.fn(); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: + options.sessionDir === undefined + ? undefined + : { summary: { sessionDir: options.sessionDir } }, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + track, + showError: vi.fn(), + openUndoSelector: vi.fn(), + cancelRunningShellCommand: vi.fn(), + } as unknown as EditorKeyboardHost; + if (options.imageLimits !== undefined) { + (host as unknown as { harness: KimiHarness }).harness = { + imageLimits: options.imageLimits, + } as unknown as KimiHarness; + } + + const controller = new EditorKeyboardController(host, store); + controller.install(); + + return { + store, + track, + async pasteImage() { + const handler = editor['onPasteImage']; + if (handler === undefined) throw new Error('onPasteImage handler not installed'); + await (handler as () => Promise<boolean>)(); + }, + }; +} + +async function solidPng(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +async function solidJpeg(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/jpeg', { quality: 90 }), + ); +} + +/** + * Insert a minimal EXIF APP1 segment carrying only an Orientation tag right + * after the JPEG SOI marker (jimp itself never writes EXIF). Mirrors the + * fixture in agent-core's image-compress tests. + */ +function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array { + // TIFF body, little-endian: 8-byte header + IFD0 with a single entry. + const tiff = Buffer.alloc(26); + tiff.write('II', 0, 'latin1'); + tiff.writeUInt16LE(42, 2); + tiff.writeUInt32LE(8, 4); // offset of IFD0 + tiff.writeUInt16LE(1, 8); // one directory entry + tiff.writeUInt16LE(0x0112, 10); // tag: Orientation + tiff.writeUInt16LE(3, 12); // type: SHORT + tiff.writeUInt32LE(1, 14); // count + tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field + tiff.writeUInt32LE(0, 22); // no next IFD + const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const app1Header = Buffer.alloc(4); + app1Header.writeUInt16BE(0xff_e1, 0); + app1Header.writeUInt16BE(exifBody.length + 2, 2); + return new Uint8Array( + Buffer.concat([ + Buffer.from(jpeg.subarray(0, 2)), // SOI + app1Header, + exifBody, + Buffer.from(jpeg.subarray(2)), + ]), + ); +} + +describe('clipboard image paste compression', () => { + beforeEach(() => { + readClipboardMedia.mockReset(); + }); + + it('downsamples an oversized pasted image before storing it', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + expect(store.size()).toBe(1); + const att = store.get(1); + expect(att?.kind).toBe('image'); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + + // Stored metadata reflects the compressed size. + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×1000'); + + // The stored bytes decode to the compressed dimensions — the thumbnail and + // the submitted image both read from these bytes, so they cannot diverge. + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + }); + + it('honors the harness [image] max_edge_px when pasting', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ + imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }), + }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + // The harness [image] config — not the built-in 2000px — drives ingestion. + expect(Math.max(att.width, att.height)).toBe(800); + expect(att.placeholder).toContain('800×400'); + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBe(800); + }); + + it('records and persists the pre-compression original for an oversized paste', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original).toBeDefined(); + expect(att.original?.width).toBe(3600); + expect(att.original?.height).toBe(1800); + expect(att.original?.byteLength).toBe(big.length); + expect(att.original?.mime).toBe('image/png'); + + // The original bytes are readable back from the persisted path. + expect(att.original?.path).not.toBeNull(); + const persisted = await readFile(att.original!.path!); + expect(new Uint8Array(persisted)).toEqual(big); + await unlink(att.original!.path!).catch(() => undefined); + }); + + it('persists the original into the session media-originals dir when the session is known', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-paste-session-')); + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ sessionDir }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.path).not.toBeNull(); + expect(att.original!.path!.startsWith(join(sessionDir, 'media-originals'))).toBe(true); + const persisted = await readFile(att.original!.path!); + expect(new Uint8Array(persisted)).toEqual(big); + await rm(sessionDir, { recursive: true, force: true }); + }); + + it('stores a within-budget paste byte-for-byte', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.width).toBe(80); + expect(att.height).toBe(80); + expect(att.bytes).toBe(small); // identity: no re-encode on the fast path + expect(att.original).toBeUndefined(); + }); + + it( + 'records an EXIF-rotated compressed original in display space', + async () => { + // Orientation 6 (rotate 90° CW): the header says 3600x400, but the image + // decodes to 400x3600 — the space the compressed bytes and any later + // ReadMediaFile region readback live in. The recorded original (which + // drives the submit-time compression caption) must match that space, or + // the caption contradicts the sent image's aspect and region coordinates + // land axis-swapped. (Kept narrow: pure-JS decode+rotate+encode of a + // larger frame can outlast the test timeout on slow CI runners.) + const portrait = withExifOrientation(await solidJpeg(3600, 400), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.width).toBe(400); + expect(att.original?.height).toBe(3600); + // The compressed attachment itself keeps the portrait aspect. + expect(att.width).toBeLessThan(att.height); + await unlink(att.original!.path!).catch(() => undefined); + }, + 15_000, + ); + + it('stores display-space dimensions for an EXIF-rotated untouched paste', async () => { + // Within budgets → sent byte-for-byte, but the placeholder and metadata + // must still describe the display (rotated) space. + const portrait = withExifOrientation(await solidJpeg(120, 80), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.bytes).toBe(portrait); // fast path — untouched + expect(att.original).toBeUndefined(); + expect(att.width).toBe(80); + expect(att.height).toBe(120); + expect(att.placeholder).toContain('80×120'); + }); + + it('emits image_compress telemetry tagged tui_paste through host.track', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { track, pasteImage } = createPasteHarness(); + await pasteImage(); + + const compressCalls = track.mock.calls.filter(([event]) => event === 'image_compress'); + expect(compressCalls).toHaveLength(1); + const props = compressCalls[0]![1] as Record<string, unknown>; + expect(props['source']).toBe('tui_paste'); + expect(props['outcome']).toBe('compressed'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts new file mode 100644 index 000000000..090d47d50 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/kimi-tui'; +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; + +interface Harness { + readonly host: EditorKeyboardHost; + readonly editor: Record<string, ((...args: never[]) => unknown) | undefined>; + readonly openUndoSelector: ReturnType<typeof vi.fn>; + readonly cancelRunningShellCommand: ReturnType<typeof vi.fn>; +} + +function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + setInputMode: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const openUndoSelector = vi.fn(); + const cancelRunningShellCommand = vi.fn(); + const session = { cancel: vi.fn(async () => {}) }; + + const host = { + state: { + editor, + activeDialog: null, + appState: { + streamingPhase: options.streamingPhase ?? 'idle', + isCompacting: options.isCompacting ?? false, + }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + openUndoSelector, + cancelRunningShellCommand, + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + return { host, editor, openUndoSelector, cancelRunningShellCommand }; +} + +function pressEscape(editor: Harness['editor']): void { + const handler = editor['onEscape']; + if (handler === undefined) throw new Error('onEscape handler not installed'); + (handler as () => void)(); +} + +function pressNonEscape(editor: Harness['editor']): void { + const handler = editor['onNonEscapeInput']; + if (handler === undefined) throw new Error('onNonEscapeInput handler not installed'); + (handler as () => void)(); +} + +describe('EditorKeyboardController double-Esc undo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens the undo selector when Esc is pressed twice within the window while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + expect(openUndoSelector).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(openUndoSelector).toHaveBeenCalledOnce(); + }); + + it('does nothing for a single Esc while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when the second Esc arrives after the window expires', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + vi.advanceTimersByTime(DOUBLE_ESC_WINDOW_MS + 1); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when another key is pressed between the two Esc presses', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + pressNonEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger undo while streaming; Esc cancels the stream instead', () => { + const { editor, host, openUndoSelector, cancelRunningShellCommand } = createHarness({ + streamingPhase: 'waiting', + }); + + pressEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + expect(cancelRunningShellCommand).toHaveBeenCalled(); + const session = host.session as unknown as { cancel: ReturnType<typeof vi.fn> }; + expect(session.cancel).toHaveBeenCalled(); + }); +}); + +describe('EditorKeyboardController shell history recall', () => { + type Recall = (entry: string, direction: 1 | -1) => string | undefined; + type Mock = ReturnType<typeof vi.fn>; + + it('installs a filter that allows shell entries only in bash mode', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + expect(setHistoryFilter).toHaveBeenCalledOnce(); + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(true); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(false); + }); + + it('locks the filter to the browse-entry mode once browsing starts', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + // Enter browse from prompt mode, then simulate landing on a shell entry + // (which flips inputMode to bash). The filter should stay locked to prompt + // and keep allowing plain entries. + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + save(); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + + expect(filter('hello')).toBe(true); + expect(filter('!cmd')).toBe(true); + }); + + it('strips the leading ! and switches to bash mode when recalling a shell entry', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('!cmd', -1); + + expect(result).toBe('cmd'); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('bash'); + }); + + it('keeps plain entries as-is and switches to prompt mode', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('hello', -1); + + expect(result).toBeUndefined(); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); + + it('saves the current input mode as the history draft host state', () => { + const { editor } = createHarness(); + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(save()).toBe('prompt'); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(save()).toBe('bash'); + }); + + it('restores the input mode from the saved draft host state', () => { + const { editor } = createHarness(); + const restore = editor['onHistoryDraftRestore'] as unknown as (state: unknown) => void; + + restore('prompt'); + + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 313da543a..8b9d5fbdf 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; -import { getColorPalette } from '#/tui/theme/colors'; +import { getBuiltInPalette } from '#/tui/theme'; import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; vi.mock('#/tui/goal-queue-store', () => ({ @@ -54,7 +54,8 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], - theme: { colors: getColorPalette('dark') }, + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, @@ -68,10 +69,14 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { flushNow: vi.fn(), resetToolUi: vi.fn(), finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), hasThinkingDraft: vi.fn(() => false), flushThinkingToTranscript: vi.fn(), appendAssistantDelta: vi.fn(), scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), }, requireSession: vi.fn(() => session), setAppState: vi.fn(), @@ -139,6 +144,20 @@ function turnEndedEvent() { } as const; } +function compactionCompletedEvent() { + return { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { + summary: 'summary', + tokensBefore: 100, + tokensAfter: 10, + compactedCount: 1, + }, + } as const; +} + function modelBlockedEvent() { return { type: 'goal.updated', @@ -185,7 +204,6 @@ describe('SessionEventHandler goal queue promotion', () => { text: 'Ship queued goal', }); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); }); it('waits for queued user input to drain before promoting the next queued goal', async () => { @@ -235,6 +253,76 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); }); + it('defers queued-goal promotion while a queued message is mid-dispatch', async () => { + const { host, session } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + host.state.queuedMessages = []; + // The queue looks empty and the phase is idle, but a shifted queued message + // is still awaiting its deferred send. Promotion must not jump ahead of it. + host.state.queuedMessageDispatchPending = true; + const handler = new SessionEventHandler(host); + + handler.requestQueuedGoalPromotion(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + // Once the queued message has been dispatched, the flag clears and the + // promotion proceeds on the next retry. + host.state.queuedMessageDispatchPending = false; + handler.retryQueuedGoalPromotion(); + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + }); + + it('waits for a queued user input drained after compaction before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.appState.isCompacting = true; + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + const handler = new SessionEventHandler(host); + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; + Object.assign(host.state.appState, patch); + if (busyChanged) handler.retryQueuedGoalPromotion(); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const sendQueued = sendQueuedViaHost(host, session); + + handler.requestQueuedGoalPromotion(); + handler.handleEvent(compactionCompletedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + const sendQueuedCalls = host.sendQueuedMessage.mock.calls as Array<[unknown, { text?: string }]>; + const userMessageIndex = sendQueuedCalls.findIndex( + ([, item]) => item.text === 'queued user turn', + ); + expect(userMessageIndex).toBeGreaterThanOrEqual(0); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!; + const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!; + expect(userMessageOrder).toBeLessThan(goalCreateOrder); + }); + it('leaves the queued goal in place when the next goal cannot start', async () => { const { host, session } = makeHost({ createGoalRejects: true }); const handler = new SessionEventHandler(host); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 04bdfacd2..0899cf070 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -8,11 +8,13 @@ function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/kimi-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -56,12 +58,12 @@ describe('createTUIState', () => { expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); expect(state.todoPanel).toBeDefined(); - expect(state.theme.colors).toBeDefined(); - expect(state.theme.markdownTheme).toBeDefined(); + expect(state.theme.palette).toBeDefined(); // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); expect(state.appState.model).toBe('test-model'); + expect(state.appState.additionalDirs).toEqual([]); expect(state.appState.sessionId).toBe('sess-1'); expect(state.startupState).toBe('pending'); @@ -79,6 +81,7 @@ describe('createTUIState', () => { expect(state.activeDialog).toBeNull(); expect(state.externalEditorRunning).toBe(false); expect(state.loadingSessions).toBe(false); + expect(state.sessionsScope).toBe('cwd'); expect(state.activitySpinner).toBeNull(); }); }); diff --git a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts index 9373018f2..5406a2998 100644 --- a/apps/kimi-code/test/tui/easter-eggs/dance.test.ts +++ b/apps/kimi-code/test/tui/easter-eggs/dance.test.ts @@ -168,7 +168,7 @@ describe('installRainbowDance', () => { const dispose = installRainbowDance(requestRender); const host = { showStatus: vi.fn(), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); @@ -202,7 +202,7 @@ function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: strin setRainbowDance(rainbowDance); const host = { showStatus: (msg: string) => status.push(msg), - state: { theme: { colors: darkColors } }, + state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; return { host, calls, status }; } diff --git a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts index cb3e88c59..6add1e428 100644 --- a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts +++ b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts @@ -59,4 +59,30 @@ describe('ImageAttachmentStore', () => { const next = s.addImage(new Uint8Array(), 'image/png', 10, 10); expect(next.id).toBe(1); }); + + it('remove() drops a single attachment without resetting ids', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + expect(s.size()).toBe(2); + s.remove(a.id); + expect(s.size()).toBe(1); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(b.id)).toBe(b); + // Unlike clear(), remove() must not reset the id counter. + const next = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + expect(next.id).toBe(3); + }); + + it('removeMany() drops many attachments at once', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + const c = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + s.removeMany([a.id, c.id]); + expect(s.size()).toBe(1); + expect(s.get(b.id)).toBe(b); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(c.id)).toBeUndefined(); + }); }); diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index cdc74e913..e157cbf8c 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -1,7 +1,13 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, it, expect } from 'vitest'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { getCacheDir } from '#/utils/paths'; function storeWith( bytes: Uint8Array, @@ -13,6 +19,36 @@ function storeWith( return { store, placeholder: att.placeholder }; } +/** Point `getCacheDir()` at a fresh temp home for the duration of a test. */ +function setupTempCache(): { cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), 'kimi-home-')); + const prev = process.env[KIMI_CODE_HOME_ENV]; + process.env[KIMI_CODE_HOME_ENV] = home; + return { + cleanup: () => { + if (prev === undefined) delete process.env[KIMI_CODE_HOME_ENV]; + else process.env[KIMI_CODE_HOME_ENV] = prev; + rmSync(home, { recursive: true, force: true }); + }, + }; +} + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), 'kimi-src-')); +} + +type TextPart = { type: 'text'; text: string }; + +function videoPathFromParts(parts: unknown[]): string { + const text = parts + .filter((p): p is TextPart => (p as TextPart).type === 'text') + .map((p) => p.text) + .join(''); + const m = /<video path="([^"]+)"><\/video>/.exec(text); + if (!m) throw new Error(`no video tag found in: ${text}`); + return m[1]!; +} + describe('extractMediaAttachments', () => { it('returns no parts and hasMedia=false for plain text', () => { const store = new ImageAttachmentStore(); @@ -52,18 +88,30 @@ describe('extractMediaAttachments', () => { }); it('keeps matched-placeholder order with mixed image and video attachments', () => { - const store = new ImageAttachmentStore(); - const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); - const vid = store.addVideo('video/quicktime', '/tmp/clip.mov'); - const text = `first ${img.placeholder} then ${vid.placeholder} end`; - const r = extractMediaAttachments(text, store); - expect(r.imageAttachmentIds).toEqual([1]); - expect(r.videoAttachmentIds).toEqual([2]); - expect(r.parts).toEqual([ - { type: 'text', text: 'first ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, - { type: 'text', text: ' then <video path="/tmp/clip.mov"></video> end' }, - ]); + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const vid = store.addVideo('video/quicktime', srcVideo); + const text = `first ${img.placeholder} then ${vid.placeholder} end`; + const r = extractMediaAttachments(text, store); + expect(r.imageAttachmentIds).toEqual([1]); + expect(r.videoAttachmentIds).toEqual([2]); + expect(r.parts[0]).toEqual({ type: 'text', text: 'first ' }); + expect(r.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQ==' }, + }); + const cachePath = videoPathFromParts(r.parts); + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(readFileSync(cachePath, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); it('leaves unresolved (typed by hand) placeholders as literal text', () => { @@ -85,20 +133,91 @@ describe('extractMediaAttachments', () => { }); it('escapes media paths in generated tags', () => { - const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/a&"<>.mp4', 'sample.mp4'); - const r = extractMediaAttachments(att.placeholder, store); - expect(r.parts).toEqual([ - { type: 'text', text: '<video path="/tmp/a&"<>.mp4"></video>' }, - ]); + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'source.mp4'); + writeFileSync(srcVideo, 'x'); + const store = new ImageAttachmentStore(); + // The filename drives the cache label; `&` must be escaped in the attribute. + const att = store.addVideo('video/mp4', srcVideo, 'a&b.mp4'); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.parts).toHaveLength(1); + const text = (r.parts[0] as TextPart).text; + expect(text).toMatch(/<video path="[^"]+a&b\.mp4"><\/video>/); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); - it('expands video placeholders backed by local files to readMediaFile video tags', () => { + it('copies video placeholders into the cache and emits cache-path tags', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'sample.mp4'); + writeFileSync(srcVideo, 'video-data'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/mp4', srcVideo); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const cachePath = videoPathFromParts(r.parts); + // The tag points at the cache, not the original source path. + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(cachePath).not.toBe(srcVideo); + expect(readFileSync(cachePath, 'utf8')).toBe('video-data'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('inserts a compression caption before an image that was compressed at paste time', () => { const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/sample.mp4'); + const att = store.addImage(new Uint8Array([1, 2, 3]), 'image/png', 2000, 2000, { + path: '/tmp/kimi-code-original-images/abc.png', + width: 2600, + height: 2600, + byteLength: 123456, + mime: 'image/png', + }); + + const r = extractMediaAttachments(`look ${att.placeholder}`, store); + + expect(r.parts).toHaveLength(2); + const caption = r.parts[0]; + if (caption?.type !== 'text') throw new Error('expected leading text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain('/tmp/kimi-code-original-images/abc.png'); + expect(r.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQID' }, + }); + }); + + it('notes an unpreserved original when persistence failed at paste time', () => { + const store = new ImageAttachmentStore(); + const att = store.addImage(new Uint8Array([1]), 'image/png', 2000, 2000, { + path: null, + width: 2600, + height: 2600, + byteLength: 123456, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); - expect(r.hasMedia).toBe(true); - expect(r.videoAttachmentIds).toEqual([1]); - expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]); + + const caption = r.parts[0]; + if (caption?.type !== 'text') throw new Error('expected leading text part'); + expect(caption.text).toMatch(/not preserved/i); + }); + + it('adds no caption for an uncompressed image attachment', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa])); + const r = extractMediaAttachments(placeholder, store); + expect(r.parts).toHaveLength(1); + expect(r.parts[0]?.type).toBe('image_url'); }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 42200cfa0..a014a4586 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1,17 +1,19 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { AgentSwarmProgressComponent, agentSwarmGridHeightForTerminalRows, @@ -20,27 +22,52 @@ import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; +import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, } from '#/tui/components/dialogs/plugins-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; }); +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/feedback/codebase')>(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCodebase: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + +// /feedback falls back to opening GitHub Issues in a browser when not signed in +// or when submission fails — stub it out so the test suite never spawns a +// browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); const ESC = String.fromCodePoint(0x1b); @@ -62,12 +89,13 @@ interface MessageDriver { init(): Promise<boolean>; handleUserInput(text: string): void; persistInputHistory(text: string): Promise<void>; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; getCurrentSessionId(): string; } interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise<void>; - promptFeedbackInput(): Promise<string | undefined>; + promptFeedbackInput(): Promise<FeedbackPromptResult | undefined>; } interface ModelSelectorDriver extends MessageDriver { @@ -100,13 +128,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } @@ -124,7 +152,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -148,7 +176,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { main: { status: { model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -172,12 +200,14 @@ function makeSession(overrides: Record<string, unknown> = {}) { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', })), setPluginEnabled: vi.fn(async () => {}), setPluginMcpServerEnabled: vi.fn(async () => {}), removePlugin: vi.fn(async () => {}), reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), reloadSession: vi.fn(async () => ({})), + activateSkill: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -199,6 +229,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { } function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { + const interactiveAgentScope = new AsyncLocalStorage<string>(); return { getConfig: vi.fn(async () => ({ models: { @@ -210,10 +241,21 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - interactiveAgentId: 'main', + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(), @@ -221,8 +263,11 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, @@ -251,6 +296,13 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +async function confirmUndoSelection(driver: MessageDriver): Promise<void> { + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); + }); + (driver.state.editorContainer.children[0] as UndoSelectorComponent).handleInput('\r'); +} + function renderActivity(driver: MessageDriver): string { return driver.state.activityContainer.render(120).join('\n'); } @@ -309,6 +361,14 @@ async function makeTempHome(): Promise<string> { return dir; } +async function makeExportedSessionZip(content = 'session zip'): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-code-feedback-export-')); + tempDirs.push(dir); + const zipPath = join(dir, 'session.zip'); + await writeFile(zipPath, content); + return zipPath; +} + afterEach(async () => { resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { @@ -341,7 +401,6 @@ describe('KimiTUI message flow', () => { const { driver, harness } = await makeDriver(); harness.track.mockClear(); - driver.state.editor.handleInput('\u001B[106;5u'); driver.state.editor.handleInput('\u001F'); delete process.env['VISUAL']; delete process.env['EDITOR']; @@ -349,7 +408,6 @@ describe('KimiTUI message flow', () => { driver.state.editor.onToggleToolExpand?.(); driver.state.editor.onTextPaste?.(); - expect(harness.track).toHaveBeenCalledWith('shortcut_newline', undefined); expect(harness.track).toHaveBeenCalledWith('undo', undefined); expect(harness.track).toHaveBeenCalledWith('shortcut_editor', undefined); expect(harness.track).toHaveBeenCalledWith('shortcut_expand', undefined); @@ -452,8 +510,9 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.track.mockClear(); await handleFeedbackCommand(feedbackDriver as any); @@ -467,6 +526,309 @@ command = "vim" }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record<string, never>; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise<void>((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); + }); + + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record<string, unknown>]>; + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { @@ -485,7 +847,8 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, @@ -549,7 +912,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 0, @@ -728,7 +1091,7 @@ command = "vim" let resolveSnapshot: ( servers: Array<{ name: string; - transport: 'stdio' | 'http'; + transport: 'stdio' | 'http' | 'sse'; status: 'pending' | 'connected' | 'failed' | 'disabled'; toolCount: number; error?: string; @@ -803,6 +1166,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -830,6 +1194,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([]); @@ -853,7 +1218,15 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); }); + driver.handleUserInput('/undo 10'); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', + ); + }); + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -861,6 +1234,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Cannot undo 10 prompts'); expect(transcript).toContain('Auto mode: ON'); expect(driver.state.appState.permissionMode).toBe('auto'); }); @@ -898,6 +1272,7 @@ command = "vim" }); driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -944,6 +1319,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -987,6 +1363,7 @@ command = "vim" }); driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(1); @@ -997,6 +1374,49 @@ command = "vim" expect(transcript).not.toContain('Approved: Run shell command'); }); + it('removes debug timing status from undone turns', async () => { + const { driver, session } = await makeDriver(); + const previousDebug = process.env['KIMI_CODE_DEBUG']; + process.env['KIMI_CODE_DEBUG'] = '1'; + try { + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + llmFirstTokenLatencyMs: 120, + llmStreamDurationMs: 800, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('[Debug]'); + }); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('[Debug]'); + } finally { + if (previousDebug === undefined) { + delete process.env['KIMI_CODE_DEBUG']; + } else { + process.env['KIMI_CODE_DEBUG'] = previousDebug; + } + } + }); + it('undoes multiple turns when a count is provided', async () => { const { driver, session } = await makeDriver(); @@ -1065,6 +1485,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([]); @@ -1093,6 +1514,7 @@ command = "vim" driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); await vi.waitFor(() => { expect(driver.state.transcriptEntries).toEqual([ @@ -1151,12 +1573,32 @@ command = "vim" const { driver, session } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); driver.state.editor.onEscape?.(); expect(session.cancel).toHaveBeenCalledTimes(1); + expect(driver.state.editor.getText()).toBe('draft while streaming'); session.cancel.mockClear(); driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText(''); + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + }); + + it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + driver.state.editor.onCtrlC?.(); expect(session.cancel).toHaveBeenCalledTimes(1); @@ -1192,6 +1634,151 @@ command = "vim" } }); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); + + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('persists bash input to input history with a leading !', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls'); + }); + + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); + }); + + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); + + it('does not steer while a shell command is running', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); + }); + + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); + + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); + }); + + it('recalls a queued bash command back into bash mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; + // After a bash command is queued the editor is reset to prompt mode. + driver.state.editor.inputMode = 'prompt'; + driver.state.appState.inputMode = 'prompt'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('ls'); + expect(driver.state.editor.inputMode).toBe('bash'); + expect(driver.state.appState.inputMode).toBe('bash'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('recalls a queued prompt message in prompt mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'hello', agentId: 'main' }]; + driver.state.editor.inputMode = 'bash'; + driver.state.appState.inputMode = 'bash'; + + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('hello'); + expect(driver.state.editor.inputMode).toBe('prompt'); + expect(driver.state.appState.inputMode).toBe('prompt'); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('echoes a bash command with a $ prompt in the transcript', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver, harness } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + await Promise.resolve(); + + expect(harness.track).toHaveBeenCalledWith('shell_command', undefined); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('$ ls'); + expect(transcript).not.toContain('! ls'); + }); + it('renders cron fired events as distinct transcript entries', async () => { const { driver } = await makeDriver(); @@ -1277,7 +1864,7 @@ command = "vim" await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } @@ -1374,6 +1961,30 @@ command = "vim" expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancelCompaction).not.toHaveBeenCalled(); + expect(driver.state.appState.isCompacting).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + it('dispatches the next queued message after compaction is cancelled', async () => { vi.useFakeTimers(); try { @@ -1412,6 +2023,83 @@ command = "vim" } }); + it('stores the live compaction summary and expands it with tool output expansion', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const collapsed = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + driver.state.editor.onToggleToolExpand?.(); + + const expanded = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + }); + + it('honors existing tool output expansion when a compaction block is created', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const transcript = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Keep the src/tui compaction notes.'); + }); + it('renders an error instead of prompting when no model is selected', async () => { const { driver, session } = await makeDriver(); driver.state.appState.model = ''; @@ -1712,10 +2400,10 @@ command = "vim" expect(finalLines.at(-1)).toMatch(/^│\s+│$/); }); - it('caps /btw height to half the terminal and supports scrolling', async () => { + it('caps /btw height to one-third of the terminal and supports scrolling', async () => { const session = makeSession(); const { driver } = await makeDriver(session); - setTerminalRows(driver, 12); + setTerminalRows(driver, 15); await openBtwPanel(driver, session, 'question 1'); const panel = getMountedBtwPanel(driver); @@ -1728,7 +2416,7 @@ command = "vim" } const collapsed = panel.render(80).map(stripSgr); - expect(collapsed).toHaveLength(6); + expect(collapsed).toHaveLength(5); expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll'); expect(collapsed.join('\n')).not.toContain('ctrl+o expand'); expect(collapsed.join('\n')).toContain('question 8'); @@ -2875,7 +3563,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 25, @@ -2895,7 +3583,7 @@ command = "vim" expect(output).toContain(' Status '); expect(output).toContain('>_ Kimi Code'); expect(output).toContain('Model'); - expect(output).toContain('thinking on'); + expect(output).toContain('thinking high'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); expect(output).toContain('Context window'); @@ -3019,15 +3707,46 @@ command = "vim" expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('installs from a positional source on /plugins install', async () => { + it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith( + resolve('/tmp/proj-a', './plugins/kimi-datasource'), + ); + }); + }); + + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); it('loads a local plugin marketplace file and installs from it', async () => { @@ -3039,9 +3758,10 @@ command = "vim" plugins: [ { id: 'kimi-datasource', + tier: 'official', displayName: 'Kimi Datasource', description: 'Datasource plugin', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -3054,21 +3774,194 @@ command = "vim" driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput('\r'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + // Official loads its catalog lazily; wait for the entry to render before install. + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); + // Installing closes the panel so the success notice / reload tip is visible. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + }); + + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); + }); + + it('restores the panel when a third-party marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(panel); + }); + }); + + it('removes a plugin record without auto-running any cleanup skill', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins remove kimi-webbridge'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginRemoveConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent; + confirm.handleInput('\u001B[B'); + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.removePlugin).toHaveBeenCalledWith('kimi-webbridge'); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); it('installs default marketplace entries through plain install', async () => { @@ -3091,12 +3984,16 @@ command = "vim" driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - picker.handleInput('\r'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + // The pinned Kimi WebBridge row leads the Official tab, so move down to + // the Kimi Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( @@ -3109,7 +4006,41 @@ command = "vim" } }); - it('toggles plugins from the overview with space', async () => { + it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { + const originalFetch = globalThis.fetch; + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('fetch failed'); + }), + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + try { + driver.handleUserInput('/plugins'); + + // The panel opens immediately on the Installed tab — no marketplace fetch. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('\t'); // → Official, which lazily (and unsuccessfully) loads + + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain( + 'Marketplace unavailable: fetch failed', + ); + }); + // The panel stays mounted; the failure does not close /plugins. + expect(driver.state.editorContainer.children[0]).toBe(panel); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('toggles plugins from the Installed tab with space', async () => { let enabled = true; const session = makeSession({ listPlugins: vi.fn(async () => [ @@ -3123,6 +4054,7 @@ command = "vim" mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ]), setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => { @@ -3134,31 +4066,25 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput(' '); - // Toggling refreshes the picker in place: it must not flash back to the - // editor between the keypress and the refreshed picker mounting. - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + // Toggling refreshes the panel in place: it must not flash back to the + // editor between the keypress and the refreshed panel mounting. + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); }); - // The picker stays mounted the whole time (no editor flash), so wait for the - // refreshed render rather than for an instance swap. await vi.waitFor(() => { const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(refreshed).toContain('❯ Demo disabled require run /new to apply'); + expect(refreshed).toContain('❯ Demo disabled run /reload or /new to apply'); }); - const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).not.toContain('Space enable'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled demo. Run /reload or /new to apply.', + ); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { @@ -3222,12 +4148,10 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput('m'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('m'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -3249,9 +4173,9 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled require run /new to apply'); + expect(out).toContain('❯ data disabled run /reload or /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for kimi-datasource. Run /new to apply.', + 'Disabled MCP server data for kimi-datasource. Run /reload or /new to apply.', ); }); @@ -3325,15 +4249,17 @@ command = "vim" }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); driver.handleUserInput('/model turbo'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); const picker = driver.state.editorContainer.children[0]; - expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); expect(pickerOutput).toMatch(/Kimi K2\s+Kimi Code ← current/); expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi Code/); @@ -3352,11 +4278,56 @@ command = "vim" expect(session.setThinking).toHaveBeenCalledWith('on'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: true, + thinking: { enabled: true }, }); }); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinking).toBe(true); + expect(driver.state.appState.thinkingEffort).toBe('on'); + }); + + it('applies /model selection to the session only on Alt+S without persisting', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('on'); }); it('persists /model selection even when runtime state is unchanged', async () => { @@ -3374,27 +4345,124 @@ command = "vim" }, }, defaultModel: 'old-default', - defaultThinking: true, + thinking: { enabled: true }, })), setConfig, }); driver.handleUserInput('/model k2'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); const picker = driver.state.editorContainer.children[0]; - expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, }); }); expect(session.setModel).not.toHaveBeenCalled(); expect(session.setThinking).not.toHaveBeenCalled(); }); + it('refreshes only OAuth provider models before opening /model picker', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Old Kimi K2', + capabilities: ['thinking'], + }, + }, + })), + }); + const tui = driver as unknown as KimiTUI; + const refreshProviderModels = vi + .spyOn(tui.authFlow, 'refreshProviderModels') + .mockRejectedValue(new Error('full provider refresh should not run')); + const refreshOAuthProviderModels = vi.fn(async () => { + await Promise.resolve(); + tui.setAppState({ + availableModels: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Fresh Kimi K2', + capabilities: ['thinking'], + }, + }, + }); + return { changed: [], unchanged: ['managed:kimi-code'], failed: [] }; + }); + ( + tui.authFlow as unknown as { + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; + } + ).refreshOAuthProviderModels = refreshOAuthProviderModels; + + driver.handleUserInput('/model'); + + await vi.waitFor(() => { + const picker = driver.state.editorContainer.children[0]; + expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); + const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); + expect(output).toContain('Fresh Kimi K2'); + expect(output).not.toContain('Old Kimi K2'); + }); + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('opens /model picker after 2s when OAuth refresh is still pending', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + }, + })), + }); + const tui = driver as unknown as KimiTUI; + const refreshOAuthProviderModels = vi.fn(() => new Promise<never>(() => {})); + ( + tui.authFlow as unknown as { + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; + } + ).refreshOAuthProviderModels = refreshOAuthProviderModels; + + vi.useFakeTimers(); + try { + driver.handleUserInput('/model'); + await Promise.resolve(); + + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); + + await vi.advanceTimersByTimeAsync(1_999); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); + + await vi.advanceTimersByTimeAsync(1); + const picker = driver.state.editorContainer.children[0]; + expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); + const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); + expect(output).toContain('Kimi K2'); + } finally { + vi.useRealTimers(); + } + }); + it('enables search in the shared model selector helper', async () => { const { driver } = await makeDriver(); const selection = runModelSelector(driver as any, { @@ -3545,6 +4613,58 @@ command = "vim" expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); + it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => { + const { driver } = await makeDriver(); + + // Turn begins -> waiting mode shows the moon spinner. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + + // Encrypted reasoning: thinking.delta events whose visible text is empty. + for (let i = 0; i < 3; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: '', + } as Event, + vi.fn(), + ); + } + + // The moon must stay up: still waiting, no orphan thinking component, and + // the activity pane still renders a moon frame (no blank, spinner-less gap). + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + const activity = stripSgr(renderActivity(driver)); + expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true); + + // Real thinking text finally arrives -> transition into thinking mode. + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'actual reasoning', + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(driver.state.appState.streamingPhase).toBe('thinking'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); + }); + it('finalizes an orphaned thinking component on turn end', async () => { const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'thinking'; @@ -3648,3 +4768,81 @@ command = "vim" expect(transcript).not.toContain('<hook_result'); }); }); + +describe('/model status displayName override', () => { + it('shows the overridden display name in the switch status', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Remote Turbo', + capabilities: ['thinking'], + overrides: { displayName: 'Custom Turbo' }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true }, + }); + }); + + expect(renderTranscript(driver)).toContain('Switched to Custom Turbo with thinking on.'); + expect(renderTranscript(driver)).not.toContain('Remote Turbo'); + }); +}); + +describe('/effort support_efforts override', () => { + it('rejects efforts hidden by support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high'); + }); + expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 6b0caf0c9..79a36d70f 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,29 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; -import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; +import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { describe, expect, it, vi } from 'vitest'; -import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; -import { - handleLoginCommand, - handleLogoutCommand, -} from "#/tui/commands/auth"; -import { - promptPlatformSelection, - promptLogoutProviderSelection, -} from "#/tui/commands/prompts"; +import { BannerProvider } from '#/tui/banner/banner-provider'; +import { readBannerDisplayState } from '#/tui/banner/state'; +import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { quoteShellArg } from '#/utils/shell-quote'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, OSC11_QUERY, QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, -} from "#/tui/utils/terminal-theme"; +} from '#/tui/utils/terminal-theme'; -vi.mock("#/tui/commands/prompts", async (importOriginal) => { - const actual = await importOriginal<typeof import("#/tui/commands/prompts")>(); +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => {}), +})); + +const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); interface StartupDriver { state: TUIState; @@ -50,7 +58,7 @@ interface MigrateExitDriver extends StartupDriver { } const MIGRATION_PLAN: MigrationPlan = { - sourceHome: "/x/.kimi", + sourceHome: '/x/.kimi', hasConfig: false, hasMcp: false, hasUserHistory: false, @@ -62,9 +70,8 @@ const MIGRATION_PLAN: MigrationPlan = { }; function makeStartupInput( - cliOptions: Partial<KimiTUIStartupInput["cliOptions"]> = {}, - tuiConfig: Partial<KimiTUIStartupInput["tuiConfig"]> = {}, - resolvedTheme: KimiTUIStartupInput["resolvedTheme"] = "dark", + cliOptions: Partial<KimiTUIStartupInput['cliOptions']> = {}, + tuiConfig: Partial<KimiTUIStartupInput['tuiConfig']> = {}, ): KimiTUIStartupInput { return { cliOptions: { @@ -80,27 +87,27 @@ function makeStartupInput( ...cliOptions, }, tuiConfig: { - theme: "dark", + theme: 'dark', + disablePasteBurst: false, editorCommand: null, - notifications: { enabled: true, condition: "unfocused" }, + notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, ...tuiConfig, }, - version: "0.0.0-test", - workDir: "/tmp/proj-a", - resolvedTheme, + version: '0.0.0-test', + workDir: '/tmp/proj-a', }; } function makeSession(overrides: Record<string, unknown> = {}) { return { - id: "ses-1", - model: "k2", - summary: { title: "Session title" }, + id: 'ses-1', + model: 'k2', + summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "manual", + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -123,9 +130,9 @@ function makeSession(overrides: Record<string, unknown> = {}) { function goalSnapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { return { - goalId: "goal-1", - objective: "Ship feature X", - status: "paused", + goalId: 'goal-1', + objective: 'Ship feature X', + status: 'paused', turnsUsed: 2, tokensUsed: 100, wallClockMs: 1000, @@ -145,9 +152,39 @@ function goalSnapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { }; } +function createResumeState(overrides: { permissionMode?: string; planMode?: boolean } = {}) { + return { + id: 'ses-latest', + workDir: '/tmp/proj-a', + sessionDir: '/tmp/proj-a/.kimi/sessions/ses-latest', + createdAt: Date.now(), + updatedAt: Date.now(), + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + cwd: '/tmp/proj-a', + modelCapabilities: { max_context_tokens: 100 }, + thinkingEffort: 'off', + systemPrompt: '', + }, + context: { history: [], tokenCount: 10 }, + replay: [], + permission: { mode: overrides.permissionMode ?? 'manual', rules: [] }, + plan: overrides.planMode ? { id: 'plan-1', content: '', path: '/tmp/plan.md' } : null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + } as never; +} + function loginRequiredError(): Error & { readonly code: string } { return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { - code: "auth.login_required", + code: 'auth.login_required', }); } @@ -155,7 +192,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> return { getConfig: vi.fn(async () => ({ models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession: vi.fn(async () => session), @@ -177,21 +214,21 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> function makeDriver(harness: ReturnType<typeof makeHarness>, input: KimiTUIStartupInput) { const driver = new KimiTUI(harness as never, input) as unknown as StartupDriver; - vi.spyOn(driver.state.ui, "requestRender").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "setProgress").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); return driver; } -type InputListener = Parameters<TUIState["ui"]["addInputListener"]>[0]; -const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; -const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; +type InputListener = Parameters<TUIState['ui']['addInputListener']>[0]; +const DARK_OSC11_REPORT = '\u001B]11;rgb:2828/2c2c/3434\u0007'; +const LIGHT_OSC11_REPORT = '\u001B]11;rgb:fafa/fbfb/fcfc\u0007'; function captureInputListeners(driver: StartupDriver) { const listeners: InputListener[] = []; const removeInputListener = vi.fn<() => void>(); - const write = vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); const addInputListener = vi - .spyOn(driver.state.ui, "addInputListener") + .spyOn(driver.state.ui, 'addInputListener') .mockImplementation((listener: InputListener) => { listeners.push(listener); return removeInputListener; @@ -200,13 +237,13 @@ function captureInputListeners(driver: StartupDriver) { return { listeners, removeInputListener, write, addInputListener }; } -describe("KimiTUI startup", () => { - it("creates a fresh session from startup flags and syncs runtime state", async () => { +describe('KimiTUI startup', () => { + it('creates a fresh session from startup flags and syncs runtime state', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -219,51 +256,280 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(session.setApprovalHandler).toHaveBeenCalledOnce(); expect(session.setQuestionHandler).toHaveBeenCalledOnce(); expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); - expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: "ses-1" }); - expect(driver.state.startupState).toBe("ready"); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); + expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, contextTokens: 25, maxContextTokens: 200, contextUsage: 0.125, - sessionTitle: "Session title", + sessionTitle: 'Session title', }); }); - it("resumes the latest session for --continue and marks history for replay", async () => { - const session = makeSession({ id: "ses-latest" }); + it('resumes the latest session for --continue and marks history for replay', async () => { + const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }, { id: "ses-old" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }, { id: 'ses-old' }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe("ses-latest"); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe('ses-latest'); }); - it("syncs a persisted goal when resuming a session", async () => { - const goal = goalSnapshot({ status: "blocked", terminalReason: "needs input" }); + it('applies --auto permission when resuming a session via --continue', async () => { + let permission = 'manual'; const session = makeSession({ - id: "ses-latest", + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('applies --yolo permission when resuming a session via --continue', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, yolo: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(driver.state.appState.permissionMode).toBe('yolo'); + }); + + it('applies --plan mode when resuming a session via --continue', async () => { + let planMode = false; + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async (enabled: boolean) => { + planMode = enabled; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('skips setPlanMode when the resumed session is already in plan mode', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('forces footer state to reflect --auto even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('forces footer state to reflect --plan even if getStatus lags behind', async () => { + const session = makeSession({ + id: 'ses-latest', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => {}), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('keeps --auto in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, auto: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise<void>; + } + ).finishStartup(true); + + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('keeps --plan in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, plan: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise<void>; + } + ).finishStartup(true); + + expect(driver.state.appState.planMode).toBe(true); + }); + + it('applies --auto permission when resuming an explicit session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-target', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target', auto: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('syncs a persisted goal when resuming a session', async () => { + const goal = goalSnapshot({ status: 'blocked', terminalReason: 'needs input' }); + const session = makeSession({ + id: 'ses-latest', getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); @@ -273,7 +539,7 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("syncs goal state regardless of the goal flag", async () => { + it('syncs goal state regardless of the goal flag', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), @@ -287,48 +553,48 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.goal).toEqual(goal); }); - it("clears goal state when closing the current session", async () => { + it('clears goal state when closing the current session', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - getExperimentalFeatures: vi.fn(async () => [{ id: "micro_compaction", enabled: true }]), + getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState.goal).toEqual(goal); - await driver.closeSession("test close"); + await driver.closeSession('test close'); expect(driver.state.appState.goal).toBeNull(); }); - it("passes the CLI model override when creating a fresh startup session", async () => { + it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: "kimi-code/k2.5" })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ - workDir: "/tmp/proj-a", - model: "kimi-code/k2.5", + workDir: '/tmp/proj-a', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); }); - it("applies the CLI model override when resuming a startup session", async () => { - let model = "k2"; + it('applies the CLI model override when resuming a startup session', async () => { + let model = 'k2'; const session = makeSession({ setModel: vi.fn(async (nextModel: string) => { model = nextModel; }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: "off", - permission: "manual", + thinkingEffort: 'off', + permission: 'manual', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -336,51 +602,434 @@ describe("KimiTUI startup", () => { })), }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: "kimi-code/k2.5" }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith("kimi-code/k2.5"); - expect(driver.state.appState.model).toBe("kimi-code/k2.5"); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); - it("enters picker startup for bare --session without creating a session", async () => { + it('enters picker startup for bare --session without creating a session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).not.toHaveBeenCalled(); expect(harness.resumeSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("picker"); + expect(driver.state.startupState).toBe('picker'); }); - it("clears startup picker exit confirmation before resuming a selected session", async () => { - const session = makeSession({ id: "ses-picked" }); + it('applies --auto after picking a session from bare --session', async () => { + let permission = 'manual'; + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission, + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPermission: vi.fn(async (mode: string) => { + permission = mode; + }), + }); const harness = makeHarness(session, { listSessions: vi.fn(async () => [ { - id: "ses-picked", - title: "Picked session", - workDir: "/tmp/proj-a", + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', updatedAt: Date.now(), }, ]), }); - const driver = makeDriver(harness, makeStartupInput({ session: "" })); - const stop = vi.spyOn(driver, "stop").mockResolvedValue(undefined); + const driver = makeDriver(harness, makeStartupInput({ session: '', auto: true })); + + await (driver as unknown as { initMainTui(): Promise<boolean> }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('skips setPlanMode after picking a session already in plan mode', async () => { + const session = makeSession({ + id: 'ses-picked', + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '', plan: true })); + + await (driver as unknown as { initMainTui(): Promise<boolean> }).initMainTui(); + expect(driver.state.startupState).toBe('picker'); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.planMode).toBe(true); + }); + + it('toggles the sessions picker from current cwd to all sessions with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listSessions).toHaveBeenNthCalledWith(1, { workDir: '/tmp/proj-a' }); + expect(listSessions).toHaveBeenNthCalledWith(2, {}); + expect(driver.state.sessionsScope).toBe('all'); + expect(driver.state.sessions.map((session) => session.id)).toEqual([ + 'ses-cwd', + 'ses-other-cwd', + ]); + }); + + it('toggles the sessions picker from all sessions back to current cwd with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const firstPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + firstPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + const allPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + allPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listSessions).toHaveBeenNthCalledWith(3, { workDir: '/tmp/proj-a' }); + expect(driver.state.sessionsScope).toBe('cwd'); + expect(driver.state.sessions.map((session) => session.id)).toEqual(['ses-cwd']); + }); + + it('does not remount the session picker after it is closed while a scope toggle is pending', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([currentWorkDirSession]); + return new Promise<unknown[]>((resolve) => { + resolveAllSessions = resolve; + }); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + (driver as unknown as { hideSessionPicker(): void }).hideSessionPicker(); + resolveAllSessions?.([currentWorkDirSession, otherWorkDirSession]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.activeDialog).toBeNull(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + }); + + it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession]; + return [currentWorkDirSession, otherWorkDirSession]; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const firstPicker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + firstPicker.handleInput('c'); + firstPicker.handleInput('w'); + firstPicker.handleInput('d'); + expect(firstPicker.render(160).join('\n')).toContain('Search: cwd'); + + firstPicker.handleInput('\u0001'); + await new Promise((resolve) => setImmediate(resolve)); + + const allPicker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const output = allPicker.render(160).join('\n'); + + expect(driver.state.sessionsScope).toBe('all'); + expect(output).toContain('All sessions'); + expect(output).toContain('(type to search)'); + expect(output).not.toContain('Search: cwd'); + }); + + it('does not resume a session from a different cwd and shows a cd hint', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + copyTextToClipboardMock.mockClear(); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + expect(driver.state.activeDialog).toBeNull(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Current session is in a different working directory.'); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain('Command copied to clipboard'); + }); + + it('copies a shell-safe resume command for another cwd with metacharacters', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj$(touch /tmp/pwned)', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + copyTextToClipboardMock.mockClear(); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + }); + + it('exits after picking another cwd from the startup picker', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', + updatedAt: Date.now() - 1000, + }; + const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' })); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); + copyTextToClipboardMock.mockClear(); await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; - picker.handleInput("\u0003"); - picker.handleInput("\r"); + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(resumeSession).not.toHaveBeenCalled(); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); + expect(stop).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledWith(0); + }); + + it('does not apply startup flags when switching sessions via the /sessions picker', async () => { + const initial = makeSession({ id: 'ses-1' }); + const picked = makeSession({ + id: 'ses-2', + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const harness = makeHarness(initial, { + resumeSession: vi.fn(async () => picked), + listSessions: vi.fn(async () => [ + { + id: 'ses-2', + title: 'Other session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ auto: true, plan: true })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.state.appState.sessionId).toBe('ses-2'); + expect(picked.setPermission).not.toHaveBeenCalled(); + expect(picked.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.appState.permissionMode).toBe('manual'); + expect(driver.state.appState.planMode).toBe(false); + }); + + it('clears startup picker exit confirmation before resuming a selected session', async () => { + const session = makeSession({ id: 'ses-picked' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [ + { + id: 'ses-picked', + title: 'Picked session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); + + await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); + await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0003'); + picker.handleInput('\r'); await new Promise((resolve) => setImmediate(resolve)); driver.state.editor.onCtrlC?.(); @@ -388,11 +1037,11 @@ describe("KimiTUI startup", () => { expect(stop).not.toHaveBeenCalled(); }); - it("tracks terminal theme reports while auto theme is active", () => { + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { listeners, write, addInputListener } = captureInputListeners(driver); @@ -407,22 +1056,19 @@ describe("KimiTUI startup", () => { write.mockClear(); expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); expect(write).toHaveBeenCalledWith(OSC11_QUERY); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); - expect(driver.state.appState.theme).toBe("auto"); - expect(driver.state.theme.resolvedTheme).toBe("light"); + expect(driver.state.appState.theme).toBe('auto'); expect(driver.state.ui.requestRender).toHaveBeenCalled(); }); - it("does not track terminal theme reports for explicit themes", () => { + it('does not track terminal theme reports for explicit themes', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput()) as unknown as ThemeTrackingDriver; const { write, addInputListener } = captureInputListeners(driver); @@ -433,22 +1079,42 @@ describe("KimiTUI startup", () => { expect(write).not.toHaveBeenCalled(); }); - it("disables terminal theme reports after leaving auto theme", () => { + it('disables terminal theme reports after leaving auto theme', () => { const harness = makeHarness(); const driver = makeDriver( harness, - makeStartupInput({}, { theme: "auto" }, "dark"), + makeStartupInput({}, { theme: 'auto' }), ) as unknown as ThemeTrackingDriver; const { write, removeInputListener } = captureInputListeners(driver); driver.refreshTerminalThemeTracking(); - driver.state.appState.theme = "dark"; + driver.state.appState.theme = 'dark'; driver.refreshTerminalThemeTracking(); expect(removeInputListener).toHaveBeenCalledOnce(); expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); + it("only shows provider refresh status for added models", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()); + const showStatus = vi.spyOn(driver as any, "showStatus").mockImplementation(() => {}); + vi.spyOn((driver as any).authFlow, "refreshProviderModels").mockResolvedValue({ + changed: [ + { providerId: "new-models", providerName: "New Models", added: 2, removed: 0 }, + { providerId: "removed-models", providerName: "Removed Models", added: 0, removed: 3 }, + { providerId: "metadata-only", providerName: "Metadata Only", added: 0, removed: 0 }, + ], + unchanged: [], + failed: [], + }); + + await (driver as any).refreshProviderModelsInBackground(); + + expect(showStatus).toHaveBeenCalledTimes(1); + expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); + }); + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { @@ -459,12 +1125,12 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(driver.state.startupState).toBe("ready"); - expect((driver as any).startupNotice).toContain("OAuth login expired"); + expect(driver.state.startupState).toBe('ready'); + expect((driver as any).startupNotice).toContain('OAuth login expired'); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - thinking: false, + sessionId: '', + model: '', + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -472,12 +1138,12 @@ describe("KimiTUI startup", () => { }); }); - it("preserves fresh startup yolo and plan intent after OAuth login", async () => { + it('preserves fresh startup yolo and plan intent after OAuth login', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "yolo", + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', planMode: true, contextTokens: 10, maxContextTokens: 100, @@ -490,10 +1156,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: false, + defaultModel: 'k2', + thinking: { enabled: false }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -503,9 +1169,9 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", - permissionMode: "yolo", + sessionId: '', + model: '', + permissionMode: 'yolo', planMode: true, }); @@ -513,31 +1179,31 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: "/tmp/proj-a", - permission: "yolo", + workDir: '/tmp/proj-a', + permission: 'yolo', planMode: true, }); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", - permission: "yolo", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'yolo', planMode: true, }); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", - permissionMode: "yolo", + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', planMode: true, }); }); - it("does not force manual permission after OAuth login without --yolo", async () => { + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ - model: "k2", - thinkingLevel: "off", - permission: "auto", + model: 'k2', + thinkingEffort: 'off', + permission: 'auto', planMode: false, contextTokens: 10, maxContextTokens: 100, @@ -550,10 +1216,10 @@ describe("KimiTUI startup", () => { .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: false, + defaultModel: 'k2', + thinking: { enabled: false }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession, @@ -565,55 +1231,58 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: "/tmp/proj-a", - model: "k2", - thinking: "off", + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', permission: undefined, planMode: undefined, }); expect(driver.state.appState).toMatchObject({ - permissionMode: "auto", + permissionMode: 'auto', }); }); - it("syncs configured thinking after OAuth login refreshes an active session", async () => { + it('does not override active session thinking when configured thinking is enabled after OAuth login', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ - defaultModel: "k2", - defaultThinking: true, + defaultModel: 'k2', + thinking: { enabled: true }, models: { - k2: { model: "moonshot-v1", maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), }); const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); - expect(driver.state.appState.thinking).toBe(false); + expect(driver.state.appState.thinkingEffort).toBe('off'); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith("k2"); - expect(session.setThinking).toHaveBeenCalledWith("on"); + expect(session.setModel).toHaveBeenCalledWith('k2'); + // `thinking.enabled === true` means "leave the session's current thinking + // level alone" — only an explicit `enabled === false` forces `'off'`. + expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - model: "k2", - thinking: true, + model: 'k2', + thinkingEffort: 'off', maxContextTokens: 100, }); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: false, }); }); - it("tracks login with already_logged_in when a token already exists", async () => { + it('tracks login with already_logged_in when a token already exists', async () => { const session = makeSession(); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -629,22 +1298,23 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); - expect(harness.track).toHaveBeenCalledWith("login", { - provider: "managed:kimi-code", + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'managed:kimi-code', + method: 'oauth', already_logged_in: true, }); }); - it("logs login failures with session context", async () => { - const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + it('logs login failures with session context', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); const session = makeSession(); - const loginError = new Error("Failed to list Kimi Code models (HTTP 402)."); + const loginError = new Error('Failed to list Kimi Code models (HTTP 402).'); const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ providers: [] })), @@ -664,20 +1334,20 @@ describe("KimiTUI startup", () => { await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - "managed:kimi-code", + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(warn).toHaveBeenCalledWith( - "login failed", + 'login failed', expect.objectContaining({ - providerName: "managed:kimi-code", + providerName: 'managed:kimi-code', alreadyLoggedIn: false, - sessionId: "ses-1", + sessionId: 'ses-1', error: expect.objectContaining({ - message: "Failed to list Kimi Code models (HTTP 402).", + message: 'Failed to list Kimi Code models (HTTP 402).', }), }), ); @@ -686,18 +1356,18 @@ describe("KimiTUI startup", () => { } }); - it("tracks logout after managed credentials and session state are cleared", async () => { + it('tracks logout after managed credentials and session state are cleared', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -709,38 +1379,36 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ - sessionId: "", - model: "", + sessionId: '', + model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); - it("keeps the active session when logging out a different provider", async () => { + it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); const removeProvider = vi.fn(async () => {}); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, providers: { - "managed:kimi-code": { type: "kimi" }, - openai: { type: "openai", baseUrl: "https://api.openai.com/v1" }, + 'managed:kimi-code': { type: 'kimi' }, + openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -752,33 +1420,33 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('openai'); await handleLogoutCommand(driver as any); - expect(removeProvider).toHaveBeenCalledWith("openai"); + expect(removeProvider).toHaveBeenCalledWith('openai'); expect(harness.auth.logout).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ - sessionId: "ses-1", - model: "k2", + sessionId: 'ses-1', + model: 'k2', }); - expect(harness.track).toHaveBeenCalledWith("logout", { provider: "openai" }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); - it("can log out a stale managed entry even after the OAuth token is gone", async () => { + it('can log out a stale managed entry even after the OAuth token is gone', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 }, }, - providers: { "managed:kimi-code": { type: "kimi" } }, + providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: "managed:kimi-code", hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -789,17 +1457,15 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue( - "managed:kimi-code", - ); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); - it("starts TUI without replaying when --continue needs OAuth login", async () => { + it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), @@ -808,29 +1474,29 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); expect(harness.createSession).not.toHaveBeenCalled(); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { + it('starts TUI without replaying when an explicit resume needs OAuth login', async () => { const harness = makeHarness(makeSession(), { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), resumeSession: vi.fn(async () => { throw loginRequiredError(); }), }); - const driver = makeDriver(harness, makeStartupInput({ session: "ses-target" })); + const driver = makeDriver(harness, makeStartupInput({ session: 'ses-target' })); await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.startupState).toBe("ready"); - expect(driver.state.appState.sessionId).toBe(""); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState.sessionId).toBe(''); }); - it("disposes terminal focus/theme tracking on the kimi migrate exit", async () => { + it('disposes terminal focus/theme tracking on the kimi migrate exit', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), @@ -838,11 +1504,11 @@ describe("KimiTUI startup", () => { migrateOnly: true, }) as unknown as MigrateExitDriver; // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen would await user input; resolve it immediately. - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); const onExit = vi.fn(async () => {}); driver.onExit = onExit; @@ -855,40 +1521,40 @@ describe("KimiTUI startup", () => { expect(onExit).toHaveBeenCalledWith(0); }); - it("disposes terminal tracking when post-migration startup fails", async () => { + it('disposes terminal tracking when post-migration startup fails', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { ...makeStartupInput(), migrationPlan: MIGRATION_PLAN, migrateOnly: false, }) as unknown as MigrateExitDriver; - vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); - vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); // The migration screen resolves "later"; startup then continues into // initMainTui(), which fails (e.g. a session-resume error). - vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); - vi.spyOn(driver, "initMainTui").mockRejectedValue(new Error("resume boom")); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - await expect(driver.start()).rejects.toThrow("resume boom"); + await expect(driver.start()).rejects.toThrow('resume boom'); // The focus tracking installed by startEventLoop() must be torn down // before the error propagates — not left active after the process exits. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); - it("keeps non-login startup session errors fatal", async () => { + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { - throw new Error("provider config is invalid"); + throw new Error('provider config is invalid'); }), }); const driver = makeDriver(harness, makeStartupInput()); - await expect(driver.init()).rejects.toThrow("provider config is invalid"); + await expect(driver.init()).rejects.toThrow('provider config is invalid'); }); - it("does not mount the footer when resuming a missing session fails", async () => { + it('does not mount the footer when resuming a missing session fails', async () => { // Regression: a stray pre-startEventLoop render used to paint the footer // (cwd/git + "context:" statusline) to the terminal before the fatal // error, leaving it stranded above the error message. The footer must not @@ -898,23 +1564,21 @@ describe("KimiTUI startup", () => { }); const driver = makeDriver( harness, - makeStartupInput({ session: "missing-session" }), + makeStartupInput({ session: 'missing-session' }), ) as unknown as MigrateExitDriver; - await expect(driver.initMainTui()).rejects.toThrow( - 'Session "missing-session" not found.', - ); + await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); }); - it("mounts the footer once startup reaches the main TUI", async () => { - const session = makeSession({ id: "ses-target" }); + it('mounts the footer once startup reaches the main TUI', async () => { + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - makeStartupInput({ session: "ses-target" }), + makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; // Not mounted until init() succeeds. @@ -925,29 +1589,164 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); - it("resumes a startup session when Windows workdir uses backslashes", async () => { - const session = makeSession({ id: "ses-target" }); + it('renders the banner below the welcome message after it loads', async () => { + const banner = { + key: 'new-banner', + tag: 'New', + mainText: 'Banner main', + subText: null, + display: 'always' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { - listSessions: vi.fn(async () => [ - { id: "ses-target", workDir: "C:/Users/kimi/project" }, - ]), + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), }); const driver = makeDriver( harness, - { - ...makeStartupInput({ session: "ses-target" }), - workDir: String.raw`C:\Users\kimi\project`, - }, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + // The banner is rendered directly below the welcome panel so it appears + // above later status messages such as MCP server connection summaries. + const welcomeIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, ); + const bannerIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof BannerComponent, + ); + expect(welcomeIndex).toBeGreaterThanOrEqual(0); + expect(bannerIndex).toBe(welcomeIndex + 1); + + loadSpy.mockRestore(); + }); + + it('writes display state after rendering a once banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'once-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'once' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + // writeBannerDisplayState runs after renderBanner; on Windows the atomic + // write can lag behind the render, so wait for the state to land before + // asserting it. + await vi.waitFor( + async () => { + const state = await readBannerDisplayState(); + expect(state.shown['once-banner']?.lastShownAt).toBeDefined(); + }, + { timeout: 5000 }, + ); + await expect(readBannerDisplayState()).resolves.toMatchObject({ + version: 1, + shown: { + 'once-banner': { + lastShownAt: expect.any(String), + }, + }, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not write display state for an always banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'always-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'always' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: {}, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resumes a startup session when Windows workdir uses backslashes', async () => { + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: 'C:/Users/kimi/project' }]), + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ session: 'ses-target' }), + workDir: String.raw`C:\Users\kimi\project`, + }); await expect(driver.init()).resolves.toBe(true); expect(harness.listSessions).toHaveBeenCalledWith({ - sessionId: "ses-target", + sessionId: 'ses-target', workDir: String.raw`C:\Users\kimi\project`, }); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); - expect(driver.state.appState.sessionId).toBe("ses-target"); + expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(driver.state.appState.sessionId).toBe('ses-target'); }); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 306e80531..5e4e11670 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import type { AgentReplayRecord, BackgroundTaskInfo, @@ -21,6 +23,8 @@ vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; +const REPLAY_TIME = 1_700_000_000_000; + function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -48,13 +52,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', - resolvedTheme: 'dark', }; } @@ -69,6 +73,7 @@ function message( } = {}, ): AgentReplayRecord { return { + time: REPLAY_TIME, type: 'message', message: { role, @@ -121,6 +126,7 @@ function goalReplay( change: GoalReplayRecord['change'], ): GoalReplayRecord { return { + time: REPLAY_TIME, type: 'goal_updated', snapshot, change, @@ -145,7 +151,7 @@ function baseAgentState( tool_use: true, max_context_tokens: 100, }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 0 }, @@ -172,7 +178,7 @@ function makeSession( summary: { title: null }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -198,6 +204,7 @@ function makeSession( } function makeHarness(initialSession: Session) { + const interactiveAgentScope = new AsyncLocalStorage<string>(); return { getConfig: vi.fn(async () => ({ models: { @@ -213,13 +220,18 @@ function makeHarness(initialSession: Session) { track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), - interactiveAgentId: 'main', + get interactiveAgentId() { + return interactiveAgentScope.getStore() ?? 'main'; + }, + withInteractiveAgent: vi.fn((agentId: string, fn: () => unknown) => { + return interactiveAgentScope.run(agentId, fn); + }), auth: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } @@ -296,6 +308,24 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<bash-stdout>pre</bash-stdout>post</bash-stdout><bash-stderr></bash-stderr>', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('pre</bash-stdout>post'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -574,6 +604,10 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); + const output = stripAnsi((group as AgentGroupComponent).render(120).join('\n')); + expect(output).toContain('2 agents finished'); + expect(output).not.toContain('Still working…'); + expect(output).not.toContain('Waiting to start…'); expect(driver.streamingUI.hasPendingAgentGroup()).toBe(false); expect(driver.streamingUI.getToolComponent('call_agent_1')).toBeUndefined(); expect(driver.streamingUI.getToolComponent('call_agent_2')).toBeUndefined(); @@ -975,13 +1009,99 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('hook response 2'); }); + it('renders replayed compaction records as completed compaction blocks', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt before compaction' }]), + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted transcript summary.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + instruction: 'preserve implementation notes', + }, + message('user', [{ type: 'text', text: 'prompt after compaction' }]), + ]); + + const compactionEntry = driver.state.transcriptEntries.find( + (entry) => entry.compactionData !== undefined, + ); + expect(compactionEntry?.compactionData).toEqual({ + summary: 'Compacted transcript summary.', + tokensBefore: 120, + tokensAfter: 24, + instruction: 'preserve implementation notes', + }); + const collapsed = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('preserve implementation notes'); + expect(collapsed).not.toContain('Compacted transcript summary.'); + + driver.state.editor.onToggleToolExpand?.(); + const expanded = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(expanded).toContain('Compacted transcript summary.'); + }); + + it('initializes replayed compaction blocks as expanded when tool output is already expanded', async () => { + const initial = makeSession([]); + const resumed = makeSession([ + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted transcript summary.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + }, + ]); + const driver = await makeDriver(initial); + driver.state.toolOutputExpanded = true; + await driver.switchToSession(resumed, 'Resumed session (ses-replay).'); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Compacted transcript summary.'); + }); + + it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt before cancellation' }]), + { + time: REPLAY_TIME, + type: 'compaction', + result: 'cancelled', + instruction: 'preserve implementation notes', + }, + message('user', [{ type: 'text', text: 'prompt after cancellation' }]), + ]); + + const compactionEntry = driver.state.transcriptEntries.find( + (entry) => entry.compactionData !== undefined, + ); + expect(compactionEntry?.compactionData).toEqual({ + result: 'cancelled', + instruction: 'preserve implementation notes', + }); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Compaction cancelled'); + expect(transcript).toContain('preserve implementation notes'); + expect(transcript).not.toContain('Compaction complete'); + }); + it('renders plan permission and approval replay notices', async () => { const driver = await replayIntoDriver([ - { type: 'plan_updated', enabled: true }, - { type: 'permission_updated', mode: 'auto' }, - { type: 'permission_updated', mode: 'yolo' }, - { type: 'permission_updated', mode: 'manual' }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: true }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'auto' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'yolo' }, + { time: REPLAY_TIME, type: 'permission_updated', mode: 'manual' }, { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -995,7 +1115,7 @@ describe('KimiTUI resume message replay', () => { }, }, }, - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); @@ -1014,6 +1134,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_reject', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 0, @@ -1031,6 +1152,7 @@ describe('KimiTUI resume message replay', () => { toolCalls: [toolCall('call_exit_final', 'ExitPlanMode', {})], }), { + time: REPLAY_TIME, type: 'approval_result', record: { turnId: 1, @@ -1053,7 +1175,7 @@ describe('KimiTUI resume message replay', () => { ], { toolCallId: 'call_exit_final' }, ), - { type: 'plan_updated', enabled: false }, + { time: REPLAY_TIME, type: 'plan_updated', enabled: false }, ]); const transcript = driver.state.transcriptContainer.render(120).join('\n'); diff --git a/apps/kimi-code/test/tui/render-memo.bench.ts b/apps/kimi-code/test/tui/render-memo.bench.ts new file mode 100644 index 000000000..63bf79df6 --- /dev/null +++ b/apps/kimi-code/test/tui/render-memo.bench.ts @@ -0,0 +1,115 @@ +/** + * Benchmark for the message-component render cache (Phase 1 + 1.5). + * + * Measures the cost of re-rendering a long transcript when *nothing* has + * changed — the common steady-state frame. With the render cache enabled + * ("cached (warm)") every message returns its previously computed lines, and + * the GutterContainer returns its cached concatenation, so the cost is roughly + * O(number of messages). With it disabled ("uncached") every message rebuilds + * its output (Markdown, Text, truncation) and the container rebuilds the full + * line array, which is O(total rendered lines) and dominates CPU as the + * transcript grows. + * + * Run: + * pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/render-memo.bench.ts + */ + +import { bench, describe } from 'vitest'; + +import type { Component } from '@moonshot-ai/pi-tui'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { ThinkingComponent } from '#/tui/components/messages/thinking'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { setRenderCacheEnabled } from '#/tui/utils/render-cache'; + +const WIDTH = 100; +const TRANSCRIPT_TURNS = 200; +const GUTTER = 2; + +const USER_TEXT = + 'Can you refactor the streaming renderer so that finalized assistant messages stop being re-rendered on every frame? Please keep the diff minimal and avoid touching the engine.'; + +const ASSISTANT_TEXT = [ + 'Here is a summary of the change:', + '', + '- cache the rendered lines per message component', + '- invalidate the cache when content, theme, or width changes', + '- keep the diff renderer untouched', + '', + '```ts', + 'render(width: number): string[] {', + ' if (this.cache && this.cache.width === width) return this.cache.lines;', + ' const lines = this.compute(width);', + ' this.cache = { width, lines };', + ' return lines;', + '}', + '```', + '', + 'This keeps the steady-state frame cheap while preserving correctness.', +].join('\n'); + +const THINKING_TEXT = [ + 'Let me reason through the invalidation paths carefully.', + 'The cache must be cleared on content changes, theme switches, and width changes.', + 'Width changes already trigger a full repaint, so they fall out naturally.', + 'Theme switches flow through invalidate(), so that is the hook to clear the cache.', + 'Streaming updates go through updateContent/setText, which already short-circuit when unchanged.', +].join('\n'); + +function buildMessages(turns: number): Component[] { + const components: Component[] = []; + for (let i = 0; i < turns; i++) { + components.push(new UserMessageComponent(`[${i}] ${USER_TEXT}`)); + + const assistant = new AssistantMessageComponent(); + assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`); + components.push(assistant); + + components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized')); + } + return components; +} + +function buildGutter(turns: number): GutterContainer { + const gutter = new GutterContainer(GUTTER, GUTTER); + for (const message of buildMessages(turns)) gutter.addChild(message); + return gutter; +} + +describe('render memo — flat child render', () => { + const messages = buildMessages(TRANSCRIPT_TURNS); + + // Warm up: populate every component's cache so the "cached" case measures + // steady-state cache hits rather than first-render cost. + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + for (const message of messages) message.render(WIDTH); + }); +}); + +describe('render memo — via GutterContainer', () => { + const gutter = buildGutter(TRANSCRIPT_TURNS); + + setRenderCacheEnabled(true); + gutter.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + gutter.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + gutter.render(WIDTH); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index 997230fec..cdc8709c3 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -211,6 +211,97 @@ describe('approval adapter', () => { ]); }); + it('renders the /goal start menu for a CreateGoal approval in manual mode', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Fix the failing auth tests', + completionCriterion: 'npm test -- auth exits 0', + mode: 'manual', + }, + }); + + // Objective + criterion are previewed as a brief block. + expect(adapted.display).toEqual([ + { + type: 'brief', + text: 'Start goal: Fix the failing auth tests\nDone when: npm test -- auth exits 0', + }, + ]); + // Choices mirror the manual-mode /goal start menu; mode options approve and + // carry the mode in selected_label, "Do not start" cancels. Each keeps the + // /goal menu's description. + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Switch to YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Start in Manual', + response: 'approved', + selected_label: 'manual', + description: + 'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + + it('renders the yolo-mode /goal start menu for a CreateGoal approval', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-goal-yolo', + toolName: 'CreateGoal', + action: 'Creating a goal', + display: { + kind: 'goal_start', + objective: 'Ship the feature', + mode: 'yolo', + }, + }); + + expect(adapted.display).toEqual([{ type: 'brief', text: 'Start goal: Ship the feature' }]); + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Keep YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, + ]); + }); + it('maps approved-for-session responses into core approval payloads', () => { expect( adaptPanelResponse({ diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index d565e160e..92a91e063 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -25,13 +25,13 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', - resolvedTheme: 'dark', }; } diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 8c4879369..5948ec6c8 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; @@ -63,7 +63,6 @@ function makeViewer(opts: { taskId: opts.taskInfo?.taskId ?? 'bash-aaaaaaaa', info: opts.taskInfo ?? info(), output: opts.output, - colors: darkColors, onClose: opts.onClose ?? (() => {}), }, fakeTerminal(opts.rows ?? 30, opts.columns ?? 120), @@ -145,6 +144,24 @@ describe('TaskOutputViewer — scrolling', () => { expect(out).not.toContain('line-001'); }); + it('Ctrl+D scrolls a page down', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('\u0004'); // Ctrl+D + const out = strip(viewer.render(120).join('\n')); + // Same page size as PageDown: body has 8 viewable rows, page = 7 lines. + expect(out).toContain('line-008'); + expect(out).not.toContain('line-001'); + }); + + it('Ctrl+U scrolls a page up', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('G'); // jump to bottom first + viewer.handleInput('\u0015'); // Ctrl+U + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-036'); + expect(out).not.toContain('line-050'); + }); + it('G jumps to the bottom', () => { const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); viewer.handleInput('G'); @@ -219,7 +236,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(50), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -235,7 +251,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: makeOutput(200), - colors: darkColors, onClose: () => {}, }); const out = strip(viewer.render(120).join('\n')); @@ -252,7 +267,6 @@ describe('TaskOutputViewer — live tail via setProps', () => { taskId: 'bash-aaaaaaaa', info: info(), output: same, - colors: darkColors, onClose: () => {}, }); const after = strip(viewer.render(120).join('\n')); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index a27c495cf..dacf75f5f 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; @@ -64,7 +64,6 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp tailOutput: undefined, tailLoading: false, flashMessage: undefined, - colors: darkColors, onSelect: vi.fn(), onToggleFilter: vi.fn(), onRefresh: vi.fn(), @@ -219,6 +218,41 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).not.toContain('bash-bbbbbbbb'); }); + it('filters out foreground tasks (detached === false)', () => { + const tasks = [ + task({ taskId: 'bash-foreground', detached: false, status: 'running' }), + task({ taskId: 'bash-background', detached: true, status: 'running' }), + ]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).not.toContain('bash-foreground'); + expect(out).toContain('bash-background'); + }); + + it('keeps background tasks with detached === true even when terminal', () => { + const tasks = [task({ taskId: 'bash-done', detached: true, status: 'completed' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-done'); + }); + + it('keeps ghost tasks whose detached field is undefined', () => { + // task() leaves `detached` undefined by default, mimicking reconcile ghosts. + const tasks = [task({ taskId: 'bash-ghost', status: 'lost' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-ghost'); + }); + + it('applies active filter after excluding foreground tasks', () => { + const tasks = [ + task({ taskId: 'bash-fg-running', detached: false, status: 'running' }), + task({ taskId: 'bash-bg-running', detached: true, status: 'running' }), + task({ taskId: 'bash-bg-done', detached: true, status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).not.toContain('bash-fg-running'); + expect(out).toContain('bash-bg-running'); + expect(out).not.toContain('bash-bg-done'); + }); + it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', diff --git a/apps/kimi-code/test/tui/terminal-notification.test.ts b/apps/kimi-code/test/tui/terminal-notification.test.ts index 2d5d904ee..cef7b8086 100644 --- a/apps/kimi-code/test/tui/terminal-notification.test.ts +++ b/apps/kimi-code/test/tui/terminal-notification.test.ts @@ -8,6 +8,7 @@ import { isInsideTmux, notifyTerminalOnce, supportsOsc9Notification, + supportsTerminalProgress, } from '#/tui/utils/terminal-notification'; function makeNotificationState(args: { @@ -215,6 +216,32 @@ describe('supportsOsc9Notification', () => { }); }); +describe('supportsTerminalProgress', () => { + it('detects Windows Terminal / ConEmu via env flags', () => { + expect(supportsTerminalProgress({ WT_SESSION: 'abc-123' })).toBe(true); + expect(supportsTerminalProgress({ ConEmuANSI: 'ON' })).toBe(true); + }); + + it('detects Ghostty / WezTerm via TERM_PROGRAM and TERM', () => { + expect(supportsTerminalProgress({ TERM_PROGRAM: 'ghostty' })).toBe(true); + expect(supportsTerminalProgress({ TERM: 'xterm-ghostty' })).toBe(true); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'WezTerm' })).toBe(true); + }); + + it('rejects terminals that show every OSC 9 payload as a notification', () => { + // iTerm2 treats any OSC 9 payload as a desktop notification, so the + // ConEmu-style 9;4 progress sequence must never be sent there. + expect(supportsTerminalProgress({ TERM_PROGRAM: 'iTerm.app' })).toBe(false); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false); + expect(supportsTerminalProgress({ TERM_PROGRAM: 'WarpTerminal' })).toBe(false); + expect(supportsTerminalProgress({ TERM: 'xterm-kitty' })).toBe(false); + expect(supportsTerminalProgress({ TERM: 'xterm-256color' })).toBe(false); + expect(supportsTerminalProgress({ ConEmuANSI: 'OFF' })).toBe(false); + expect(supportsTerminalProgress({ WT_SESSION: '' })).toBe(false); + expect(supportsTerminalProgress({})).toBe(false); + }); +}); + describe('isInsideTmux', () => { it('detects tmux via the TMUX env var', () => { expect(isInsideTmux({ TMUX: '/private/tmp/tmux-501/default,1234,0' })).toBe(true); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 055378376..3159f8025 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { TUIState } from "#/tui/kimi-tui"; -import { darkColors, lightColors, getColorPalette } from "#/tui/theme/colors"; -import { createThemeStyles } from "#/tui/theme/styles"; +import { darkColors, lightColors } from "#/tui/theme/colors"; +import { getBuiltInPalette } from "#/tui/theme"; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -169,29 +169,7 @@ describe('ColorPalette warning token', () => { }); it('resolves the correct palette by theme name', () => { - expect(getColorPalette('dark')).toBe(darkColors); - expect(getColorPalette('light')).toBe(lightColors); - }); -}); - -describe('ThemeStyles warning helper', () => { - it('wraps text and includes the input', () => { - const styles = createThemeStyles(darkColors); - const result = styles.warning('test'); - expect(result).toContain('test'); - }); - - it('is a function that returns a string', () => { - const darkStyles = createThemeStyles(darkColors); - expect(typeof darkStyles.warning).toBe('function'); - expect(typeof darkStyles.warning('hello')).toBe('string'); - }); - - it('creates independent style sets per palette', () => { - const darkStyles = createThemeStyles(darkColors); - const lightStyles = createThemeStyles(lightColors); - expect(darkStyles.colors.warning).toBe(darkColors.warning); - expect(lightStyles.colors.warning).toBe(lightColors.warning); - expect(darkStyles.colors.warning).not.toBe(lightStyles.colors.warning); + expect(getBuiltInPalette('dark')).toBe(darkColors); + expect(getBuiltInPalette('light')).toBe(lightColors); }); }); diff --git a/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts new file mode 100644 index 000000000..226901338 --- /dev/null +++ b/apps/kimi-code/test/tui/theme/custom-theme-loader.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getCustomThemesDir, + listCustomThemes, + listCustomThemesSync, + loadCustomTheme, + loadCustomThemeMerged, +} from '#/tui/theme/custom-theme-loader'; +import { darkColors, lightColors } from '#/tui/theme'; + +let home: string; +const originalHome = process.env['KIMI_CODE_HOME']; + +beforeEach(() => { + home = join(tmpdir(), `kimi-themes-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(home, 'themes'), { recursive: true }); + process.env['KIMI_CODE_HOME'] = home; +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalHome; + } +}); + +function writeTheme(name: string, body: unknown): void { + writeFileSync(join(getCustomThemesDir(), `${name}.json`), JSON.stringify(body), 'utf-8'); +} + +describe('custom theme loader', () => { + it('excludes reserved built-in names from the listing', async () => { + writeTheme('dark', { name: 'dark', colors: {} }); + writeTheme('light', { name: 'light', colors: {} }); + writeTheme('auto', { name: 'auto', colors: {} }); + writeTheme('solarized', { name: 'solarized', colors: { primary: '#268bd2' } }); + + expect(await listCustomThemes()).toEqual(['solarized']); + expect(listCustomThemesSync()).toEqual(['solarized']); + }); + + it('filters invalid hex values without writing to the terminal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeTheme('mixed', { + name: 'mixed', + colors: { primary: '#268bd2', text: 'not-a-hex', accent: '#ff0000' }, + }); + + const loaded = await loadCustomTheme('mixed'); + expect(loaded).toEqual({ primary: '#268bd2', accent: '#ff0000' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('returns null for a missing theme file', async () => { + expect(await loadCustomTheme('does-not-exist')).toBeNull(); + }); + + it('falls back to the dark palette for unspecified tokens by default', async () => { + writeTheme('solar-dark', { name: 'solar-dark', colors: { primary: '#268bd2' } }); + const merged = await loadCustomThemeMerged('solar-dark'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(darkColors.text); + }); + + it('falls back to the light palette when base is "light"', async () => { + writeTheme('solar-light', { + name: 'solar-light', + base: 'light', + colors: { primary: '#268bd2' }, + }); + const merged = await loadCustomThemeMerged('solar-light'); + expect(merged?.primary).toBe('#268bd2'); + expect(merged?.text).toBe(lightColors.text); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/foreground-task.test.ts b/apps/kimi-code/test/tui/utils/foreground-task.test.ts new file mode 100644 index 000000000..c7d0e2079 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/foreground-task.test.ts @@ -0,0 +1,92 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { pickForegroundTask, pickForegroundTasks } from '@/tui/utils/foreground-task'; + +function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-aaaaaaaa', + kind: 'process', + command: 'sleep 10', + description: 'Bash: sleep 10', + status: 'running', + detached: false, + pid: 1234, + exitCode: null, + startedAt: 1000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +describe('pickForegroundTask', () => { + it('returns undefined for an empty list', () => { + expect(pickForegroundTask([])).toBeUndefined(); + }); + + it('returns undefined when all tasks are detached (already background)', () => { + expect(pickForegroundTask([task({ detached: true })])).toBeUndefined(); + }); + + it('returns undefined when foreground tasks are not running', () => { + expect(pickForegroundTask([task({ status: 'completed' })])).toBeUndefined(); + expect(pickForegroundTask([task({ status: 'killed' })])).toBeUndefined(); + }); + + it('excludes question tasks', () => { + const question = task({ + kind: 'question', + questionCount: 1, + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([question])).toBeUndefined(); + }); + + it('returns the most recently started foreground running task', () => { + const older = task({ taskId: 'bash-old', startedAt: 1000 }); + const newer = task({ taskId: 'bash-new', startedAt: 2000 }); + expect(pickForegroundTask([older, newer])?.taskId).toBe('bash-new'); + }); + + it('ignores detached running tasks even if newer', () => { + const fg = task({ taskId: 'bash-fg', detached: false, startedAt: 1000 }); + const bg = task({ taskId: 'bash-bg', detached: true, startedAt: 9999 }); + expect(pickForegroundTask([bg, fg])?.taskId).toBe('bash-fg'); + }); + + it('accepts agent (subagent) foreground tasks', () => { + const agent = task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + agentId: 'child-1', + subagentType: 'coder', + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([agent])?.taskId).toBe('agent-aaaaaaaa'); + }); +}); + +describe('pickForegroundTasks', () => { + it('returns all foreground running tasks, most recently started first', () => { + const a = task({ taskId: 'bash-a', startedAt: 1000 }); + const b = task({ taskId: 'agent-b', kind: 'agent', startedAt: 3000 }); + const c = task({ taskId: 'bash-c', startedAt: 2000 }); + expect(pickForegroundTasks([a, b, c]).map((t) => t.taskId)).toEqual([ + 'agent-b', + 'bash-c', + 'bash-a', + ]); + }); + + it('excludes detached, terminal, and question tasks', () => { + const fg = task({ taskId: 'bash-fg' }); + const detached = task({ taskId: 'bash-bg', detached: true }); + const done = task({ taskId: 'bash-done', status: 'completed' }); + const question = task({ taskId: 'q', kind: 'question' } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTasks([fg, detached, done, question]).map((t) => t.taskId)).toEqual([ + 'bash-fg', + ]); + }); + + it('returns an empty array when nothing matches', () => { + expect(pickForegroundTasks([task({ detached: true })])).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index 41caefa22..ef78085c7 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -126,6 +126,92 @@ describe('refreshAllProviderModels', () => { expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); }); + it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { + const baseUrl = 'https://api.example.test/coding/v1'; + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const config: KimiConfig = { + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: { + storage: 'file', + key: resolveKimiCodeOAuthKey({ baseUrl }), + }, + }, + custom: { + type: 'openai', + baseUrl: 'https://custom.example.test/v1', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + displayName: 'Old Kimi', + }, + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'Custom M1', + }, + }, + defaultModel: 'kimi-code/kimi-for-coding', + telemetry: true, + }; + const host = makeRefreshHost(config); + const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer oauth-access-token'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Kimi', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels( + { + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken, + }, + { scope: 'oauth' }, + ); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + providerId: KIMI_CODE_PROVIDER_NAME, + providerName: 'Kimi Code', + added: 0, + removed: 0, + }, + ]); + expect(result.unchanged).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.current().models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Fresh Kimi'); + expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); + }); + it('refreshes custom-registry model capabilities even when model ids are unchanged', async () => { const registryUrl = 'https://registry.example.test/v1/models/api.json'; const providerId = 'example_chat-completions'; @@ -261,6 +347,289 @@ describe('refreshAllProviderModels', () => { expect(host.current().models?.[userAlias]).toEqual(userAliasModel); }); + it('adds custom-registry providers that appear under an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).not.toHaveBeenCalled(); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']); + expect(host.current().providers['b']).toMatchObject({ + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }); + expect(host.current().models?.['b/m1']).toEqual({ + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }); + }); + + it('removes custom-registry providers that disappear from an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'my-b': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My B', + }, + }, + defaultModel: 'my-b', + thinking: { enabled: true }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'b', + added: 0, + removed: 1, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers)).toEqual(['a']); + expect(host.current().models?.['a/m1']).toBeDefined(); + expect(host.current().models?.['b/m1']).toBeUndefined(); + expect(host.current().models?.['my-b']).toBeUndefined(); + expect(host.current().defaultModel).toBeUndefined(); + expect(host.current().thinking).toBeUndefined(); + }); + + it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const oldSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-old-token' }; + const newSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-new-token' }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey: 'sk-old-token', + source: oldSource, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey: 'sk-new-token', + source: newSource, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + const authorization = new Headers(init?.headers).get('authorization'); + if (authorization === 'Bearer sk-old-token') { + return new Response(JSON.stringify({ message: 'expired token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + expect(authorization).toBe('Bearer sk-new-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' }, m2: { id: 'm2' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(host.removeProvider).toHaveBeenCalledWith('a'); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(host.current().providers['a']?.source).toEqual(newSource); + expect(host.current().providers['b']?.source).toEqual(newSource); + expect(host.current().providers['a']?.apiKey).toBe('sk-new-token'); + expect(host.current().providers['b']?.apiKey).toBe('sk-new-token'); + expect(host.current().models?.['b/m2']).toEqual({ + provider: 'b', + model: 'm2', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm2', + }); + }); + it('ignores user-defined aliases when custom-registry metadata is unchanged', async () => { const registryUrl = 'https://registry.example.test/v1/models/api.json'; const providerId = 'example_chat-completions'; @@ -295,7 +664,7 @@ describe('refreshAllProviderModels', () => { [userAlias]: userAliasModel, }, defaultModel: userAlias, - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as KimiConfig); @@ -340,6 +709,63 @@ describe('refreshAllProviderModels', () => { expect(host.setConfig).not.toHaveBeenCalled(); expect(host.current().models?.[userAlias]).toEqual(userAliasModel); expect(host.current().defaultModel).toBe(userAlias); - expect(host.current().defaultThinking).toBe(false); + expect(host.current().thinking?.enabled).toBe(false); + }); + + it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { + const host = makeRefreshHost({ + providers: { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + models: { + 'kimi-code/kimi-deep-coder': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-deep-coder', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'kimi-code/kimi-deep-coder', + thinking: { enabled: false }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-deep-coder', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'only', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(host.current().models?.['kimi-code/kimi-deep-coder']?.capabilities).toEqual([ + 'thinking', + 'always_thinking', + 'tool_use', + ]); + expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); + expect(host.current().thinking?.enabled).toBe(true); }); }); diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts new file mode 100644 index 000000000..e7a724b43 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ESC = '\u001B'; +const BEL = '\u0007'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('sanitizeShellOutput', () => { + it('leaves plain text untouched', () => { + expect(sanitizeShellOutput('hello\nworld')).toBe('hello\nworld'); + }); + + it('strips SGR colour sequences', () => { + expect(sanitizeShellOutput(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + expect(sanitizeShellOutput(`${ESC}[1;32mbold green${ESC}[0m`)).toBe('bold green'); + }); + + it('strips CSI private modes (alt screen, cursor visibility)', () => { + expect(sanitizeShellOutput(`${ESC}[?1049h${ESC}[?25l`)).toBe(''); + expect(sanitizeShellOutput(`before${ESC}[?2004hafter`)).toBe('beforeafter'); + }); + + it('strips clear-screen and cursor-movement sequences', () => { + expect(sanitizeShellOutput(`${ESC}[2J${ESC}[Hhello`)).toBe('hello'); + expect(sanitizeShellOutput(`${ESC}[10;5Hhi`)).toBe('hi'); + }); + + it('strips OSC window titles', () => { + expect(sanitizeShellOutput(`${ESC}]0;my title${BEL}text`)).toBe('text'); + }); + + it('strips OSC 8 hyperlinks but keeps the link text', () => { + const link = `${ESC}]8;;https://example.com${ESC}\\click here${ESC}]8;;${ESC}\\`; + expect(sanitizeShellOutput(link)).toBe('click here'); + }); + + it('strips carriage returns (spinner redraw)', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); + }); + + it('strips backspace, bell and NUL', () => { + expect(sanitizeShellOutput(`a\u0008b${BEL}c\u0000d`)).toBe('abcd'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeShellOutput('a\nb\tc')).toBe('a\nb\tc'); + }); + + it('strips single-char ESC commands (reset, save/restore cursor)', () => { + expect(sanitizeShellOutput(`${ESC}c${ESC}7${ESC}8text`)).toBe('text'); + }); + + it('never throws and returns "" for non-string input', () => { + expect(sanitizeShellOutput(undefined as unknown as string)).toBe(''); + expect(sanitizeShellOutput(null as unknown as string)).toBe(''); + expect(sanitizeShellOutput(42 as unknown as string)).toBe(''); + }); + + it('handles huge input without throwing', () => { + const huge = `${ESC}[31m${'x'.repeat(2_000_000)}\r${ESC}[0m`; + expect(() => sanitizeShellOutput(huge)).not.toThrow(); + }); + + it('cleans a realistic TUI/dev-server burst down to printable text', () => { + const messy = + `${ESC}[?1049h${ESC}[?25l${ESC}[2J${ESC}[H` + + `${ESC}[1m${ESC}[32mVITE${ESC}[0m ready in 120ms\r\n` + + `${ESC}]0;dev server${BEL}` + + ` Local: http://localhost:5173/`; + const result = sanitizeShellOutput(messy); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('VITE ready in 120ms'); + expect(result).toContain('Local: http://localhost:5173/'); + }); +}); + +describe('formatBashOutputForDisplay', () => { + it('shows "(no output)" when both streams are empty', () => { + expect(stripTheme(formatBashOutputForDisplay('', ''))).toBe('(no output)'); + }); + + it('strips control sequences from stdout before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay(`${ESC}[?1049h${ESC}[31mhi${ESC}[0m\r`, '')); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('hi'); + }); + + it('strips control sequences from stderr before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay('', `err${BEL}\r`, true)); + expect(result).not.toContain(ESC); + expect(result).not.toContain(BEL); + expect(result).not.toContain('\r'); + expect(result).toContain('err'); + }); + + it('never throws on malformed / non-string input', () => { + expect(() => + formatBashOutputForDisplay(undefined as unknown as string, null as unknown as string), + ).not.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/tab-strip.test.ts b/apps/kimi-code/test/tui/utils/tab-strip.test.ts new file mode 100644 index 000000000..e4ae2d2aa --- /dev/null +++ b/apps/kimi-code/test/tui/utils/tab-strip.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; + +import { darkColors } from '#/tui/theme/colors'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; + +const ANSI_SGR = /\u001b\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function render(labels: readonly string[], width: number, activeIndex = 0): string { + const previousChalkLevel = chalk.level; + chalk.level = 3; + try { + return strip(renderTabStrip({ labels, activeIndex, width, colors: darkColors })); + } finally { + chalk.level = previousChalkLevel; + } +} + +describe('renderTabStrip', () => { + const labels = ['Installed', 'Official', 'Third-party', 'Custom']; + // Cell widths: ` ${label} ` → 11 / 10 / 13 / 8 = 42, plus 3 separators and a + // leading space → 46 columns total. + const FULL_WIDTH = 46; + + it('shows the full strip when it exactly fits', () => { + const out = render(labels, FULL_WIDTH); + expect(out).toContain('Installed'); + expect(out).toContain('Custom'); + expect(out).not.toContain('<'); + expect(out).not.toContain('>'); + }); + + it('scrolls (shows markers) when one column narrower than full fit', () => { + const out = render(labels, FULL_WIDTH - 1, 0); + expect(out).toContain('>'); + expect(out).not.toContain('Custom'); + }); + + it('does not truncate the last tab when separators just barely fit', () => { + // Regression: the old fit check summed only cell widths and ignored the + // three inter-tab spaces, so at 43–45 columns it declared a fit while the + // joined line was wider and the trailing tab got truncated. + const out = render(labels, FULL_WIDTH); + expect(out.endsWith(' Custom ')).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/thinking-config.test.ts b/apps/kimi-code/test/tui/utils/thinking-config.test.ts new file mode 100644 index 000000000..bd951d251 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/thinking-config.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + isThinkingOn, + thinkingEffortFromConfig, + thinkingEffortToConfig, +} from '@/tui/utils/thinking-config'; + +describe('thinkingEffortToConfig', () => { + it.each([ + ['off', { enabled: false }], + // 'on' is the boolean-model on-signal, not a declared effort. It must not + // be persisted as `thinking.effort` — boolean models have no effort concept + // and resolve back to 'on' at runtime via defaultThinkingEffortFor. + ['on', { enabled: true }], + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + ['max', { enabled: true, effort: 'max' }], + ] as const)('maps %s → %o', (effort, expected) => { + expect(thinkingEffortToConfig(effort)).toEqual(expected); + }); +}); + +describe('isThinkingOn', () => { + it.each([ + ['off', false], + ['on', true], + ['low', true], + ['high', true], + ['max', true], + ] as const)('%s → %s', (effort, expected) => { + expect(isThinkingOn(effort)).toBe(expected); + }); +}); + +describe('thinkingEffortFromConfig', () => { + it.each([ + [undefined, undefined], + [{}, undefined], + // enabled with no concrete effort → let the model's own default apply. + [{ enabled: true }, undefined], + [{ enabled: false }, 'off'], + [{ enabled: true, effort: 'high' }, 'high'], + // effort is honored even when enabled is not explicitly set. + [{ effort: 'max' }, 'max'], + ] as const)('%o → %s', (config, expected) => { + expect(thinkingEffortFromConfig(config)).toBe(expected); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts new file mode 100644 index 000000000..4fbc23fec --- /dev/null +++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TranscriptEntry } from '#/tui/types'; +import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; + +let seq = 0; +function makeEntry( + turnId: string | undefined, + kind: TranscriptEntry['kind'] = 'assistant', +): TranscriptEntry { + return { id: String(++seq), kind, turnId, renderMode: 'markdown', content: '' }; +} +function tool(turnId: string): TranscriptEntry { + return makeEntry(turnId, 'tool_call'); +} +function msg(turnId: string | undefined): TranscriptEntry { + return makeEntry(turnId, 'assistant'); +} + +describe('groupTurns', () => { + it('groups consecutive entries with the same turnId', () => { + const turns = groupTurns([msg('a'), tool('a'), msg('b')]); + expect(turns.map((t) => t.turnId)).toEqual(['a', 'b']); + expect(turns[0]!.entries).toHaveLength(2); + expect(turns[1]!.entries).toHaveLength(1); + }); + + it('attaches leading undefined turnId entries to the following turn', () => { + // A user message (undefined turnId) followed by its response should be one turn. + const turns = groupTurns([msg(undefined), tool('1'), msg('1')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('1'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('attaches multiple consecutive undefined entries to the following turn', () => { + const turns = groupTurns([msg(undefined), msg(undefined), msg('a')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('makes trailing undefined entries their own turn', () => { + const turns = groupTurns([msg('a'), msg(undefined)]); + expect(turns).toHaveLength(2); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[1]!.turnId).toBeUndefined(); + expect(turns[1]!.entries).toHaveLength(1); + }); +}); + +describe('turnsToTrim', () => { + it('returns empty when turn count is within maxTurns', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 5, 1).size).toBe(0); + }); + + it('does not trim within the hysteresis band', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 2, 1).size).toBe(0); // 3 <= 2 + 1 + }); + + it('trims oldest turns first', () => { + const entries = [msg('a'), msg('b'), msg('c'), msg('d')]; // 4 turns + const turns = groupTurns(entries); + const removed = turnsToTrim(turns, 2, 0); + expect(removed.has(entries[0]!)).toBe(true); + expect(removed.has(entries[1]!)).toBe(true); + expect(removed.has(entries[2]!)).toBe(false); + expect(removed.has(entries[3]!)).toBe(false); + }); + + it('never trims the most recent turn', () => { + // A single turn is never removed, even if it is huge. + const entries = Array.from({ length: 200 }, () => tool('solo')); + const turns = groupTurns(entries); // 1 turn + const removed = turnsToTrim(turns, 2, 0); + expect(removed.size).toBe(0); + }); +}); + +describe('readEnvInt', () => { + const KEY = 'KIMI_CODE_TUI_TEST_INT'; + afterEach(() => { + delete process.env[KEY]; + }); + + it('returns fallback when unset', () => { + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('reads a valid integer', () => { + process.env[KEY] = '42'; + expect(readEnvInt(KEY, 7)).toBe(42); + }); + + it('accepts 0', () => { + process.env[KEY] = '0'; + expect(readEnvInt(KEY, 7)).toBe(0); + }); + + it('falls back on negative', () => { + process.env[KEY] = '-1'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on non-integer', () => { + process.env[KEY] = 'abc'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on empty/whitespace', () => { + process.env[KEY] = ' '; + expect(readEnvInt(KEY, 7)).toBe(7); + }); +}); diff --git a/apps/kimi-code/test/tui/working-tips.test.ts b/apps/kimi-code/test/tui/working-tips.test.ts new file mode 100644 index 000000000..e2cf96c54 --- /dev/null +++ b/apps/kimi-code/test/tui/working-tips.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + WORKING_TIPS, + currentWorkingTip, + pickRandomWorkingTip, +} from '#/tui/components/chrome/working-tips'; + +describe('currentWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const now = Date.now(); + const tip = currentWorkingTip(now); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('returns the same tip for the same timestamp', () => { + const now = 1_000_000; + const first = currentWorkingTip(now); + const second = currentWorkingTip(now); + expect(first).toBe(second); + }); +}); + +describe('pickRandomWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const tip = pickRandomWorkingTip(); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('avoids the excluded text when possible', () => { + const first = pickRandomWorkingTip()!; + let different = false; + for (let i = 0; i < 50; i++) { + const next = pickRandomWorkingTip(first.text); + if (next !== undefined && next.text !== first.text) { + different = true; + break; + } + } + if (WORKING_TIPS.length > 1) { + expect(different).toBe(true); + } + }); + + it('falls back to the rotation when every tip would be excluded', () => { + // If all working tips share the same text, exclusion cannot be satisfied. + const onlyTip = WORKING_TIPS[0]; + if (onlyTip !== undefined && WORKING_TIPS.every((t) => t.text === onlyTip.text)) { + expect(pickRandomWorkingTip(onlyTip.text)).toBeDefined(); + } + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 000000000..c6aba57a4 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { runCommandAsync } from '#/utils/clipboard/clipboard-common'; + +describe('runCommandAsync', () => { + it('resolves with stdout for a successful command', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']); + expect(result.ok).toBe(true); + expect(result.stdout.toString('utf-8')).toBe('hello'); + }); + + it('resolves ok:false for a non-zero exit', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']); + expect(result.ok).toBe(false); + }); + + it('does not block when the command exceeds the timeout', async () => { + const timeoutMs = 100; + const start = Date.now(); + // The child would idle for 30s if left running; runCommandAsync must kill + // it and resolve well before that so a wedged helper cannot freeze launch. + const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + timeoutMs, + }); + const elapsed = Date.now() - start; + + expect(result.ok).toBe(false); + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts new file mode 100644 index 000000000..94e841101 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; +import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; + +function fakeClipboard(overrides: Partial<ClipboardModule>): ClipboardModule { + return { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(async () => []), + ...overrides, + }; +} + +describe('clipboardHasImage', () => { + it('returns false on Termux', async () => { + const result = await clipboardHasImage({ env: { TERMUX_VERSION: '0.118' }, platform: 'linux' }); + expect(result).toBe(false); + }); + + it('returns true when native clipboard reports an image on macOS', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on macOS when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when native clipboard throws', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => { + throw new Error('native error'); + }), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when clipboard contains a file-like native format', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => true), + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + // The focus-driven hint must not probe the clipboard on Linux: spawning + // wl-paste / xclip on Wayland perturbs seat focus and re-triggers the + // terminal focus event, creating a focus feedback loop (issue #1090). + it('returns false on Linux without reading the clipboard', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-1' }, + clipboard: clip, + }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + it('returns true on Windows when native clipboard reports an image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on Windows when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts new file mode 100644 index 000000000..72b036352 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; + +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), +})); + +vi.mock('#/utils/clipboard/clipboard-native', () => ({ + clipboard: { + setText: vi.fn(), + }, +})); + +const clipboardMock = clipboard as unknown as { setText: ReturnType<typeof vi.fn> }; +const spawnSyncMock = vi.mocked(spawnSync); + +afterEach(() => { + vi.clearAllMocks(); +}); + +beforeEach(() => { + spawnSyncMock.mockImplementation(() => { + throw new Error('platform clipboard fallback should not run'); + }); +}); + +describe('copyTextToClipboard', () => { + it('copies text with the native clipboard when available', async () => { + clipboardMock.setText.mockResolvedValue(undefined); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"'); + }); + + it('keeps native clipboard method context when copying text', async () => { + clipboardMock.setText.mockImplementation(function (this: unknown, text: string): void { + expect(this).toBe(clipboardMock); + expect(text).toBe('cd "/tmp/proj-b"'); + }); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + }); + + it('throws an Error when all platform clipboard commands fail', async () => { + clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); + + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toBeInstanceOf(Error); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toThrow( + /(?:clip\.exe|pbcopy|wl-copy|xclip) exited with code 1: missing/, + ); + }); +}); diff --git a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts index 3219cc80d..456ee3007 100644 --- a/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts +++ b/apps/kimi-code/test/utils/kimi-datasource-plugin.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; +import { resolveKimiCodeOAuthKey } from '@moonshot-ai/kimi-code-oauth'; import { describe, expect, it } from 'vitest'; const REPO_ROOT = join(import.meta.dirname, '../../../..'); @@ -114,12 +115,14 @@ describe('kimi-datasource MCP server', () => { await expect(readFile(blockedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); expect(requests).toEqual([ { + authorization: 'Bearer test-token', method: 'call_data_source_tool', params: { data_source_name: 'world_bank_open_data', api_name: 'world_bank_open_data', params: { filepath: textFile }, }, + url: '/', }, ]); } finally { @@ -129,8 +132,196 @@ describe('kimi-datasource MCP server', () => { await rm(tempDir, { recursive: true, force: true }); } }); + + it('uses env-scoped credentials and derives the datasource URL from KIMI_CODE_BASE_URL', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + const requests: unknown[] = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleMockDatasourceRequest(request, response, { + requests, + textFile: join(tempDir, 'unused.csv'), + binaryFile: join(tempDir, 'unused_payload.csv'), + blockedFile: join(tempDir, 'blocked.csv'), + }); + }); + + try { + await listen(server); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`; + const oauthHost = 'https://auth.dev.example.test'; + const scopedCredential = kimiCodeEnvCredentialName({ oauthHost, baseUrl }); + + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'expired-prod-token', expires_at: 1 }), + 'utf8', + ); + await writeFile( + join(kimiHome, 'credentials', `${scopedCredential}.json`), + JSON.stringify({ access_token: 'scoped-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_CODE_BASE_URL: baseUrl, + KIMI_CODE_OAUTH_HOST: oauthHost, + KIMI_DATASOURCE_API_URL: undefined, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { + name: 'arxiv', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(requests).toEqual([ + { + authorization: 'Bearer scoped-token', + method: 'get_data_source_desc', + params: { name: 'arxiv' }, + url: '/coding/v1/tools', + }, + ]); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('registers yuandian_law in the get_data_source_desc enum', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, KIMI_CODE_HOME: kimiHome }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/list', {}); + + const tools = ( + result.result as { + tools: Array<{ name: string; inputSchema: { properties: { name: { enum: string[] } } } }>; + } + ).tools; + const desc = tools.find((tool) => tool.name === 'get_data_source_desc'); + expect(desc?.inputSchema.properties.name.enum).toContain('yuandian_law'); + } finally { + child?.stdin.end(); + child?.kill(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('appends a request-id / tool-call-id trace line to tool results', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'kimi-datasource-plugin-')); + const kimiHome = join(tempDir, 'kimi-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + request.on('data', () => {}); + request.on('end', () => { + response.setHeader('x-request-id', 'backend-req-test'); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ is_success: true, result: { assistant: [{ type: 'text', text: 'ok' }] } }), + ); + }); + }); + + try { + await mkdir(join(kimiHome, 'credentials'), { recursive: true }); + await writeFile( + join(kimiHome, 'credentials', 'kimi-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + KIMI_CODE_HOME: kimiHome, + KIMI_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { name: 'yuandian_law' }, + }); + + const text = (result.result as { content: Array<{ text: string }> }).content[0]!.text; + expect(text).toContain('[kimi-datasource] request-id: backend-req-test · tool-call-id:'); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); }); +// Pin the expected credential file name to the canonical OAuth-key resolver so +// this test fails if the plugin's standalone digest drifts from the source of +// truth in @moonshot-ai/kimi-code-oauth. The credential file name is the OAuth +// key with its `oauth/` prefix stripped. +function kimiCodeEnvCredentialName(options: { + readonly oauthHost: string; + readonly baseUrl: string; +}): string { + return resolveKimiCodeOAuthKey(options).replace(/^oauth\//, ''); +} + async function readJson(request: IncomingMessage): Promise<unknown> { let body = ''; for await (const chunk of request) { @@ -150,7 +341,11 @@ async function handleMockDatasourceRequest( }, ): Promise<void> { try { - options.requests.push(await readJson(request)); + options.requests.push({ + ...(await readJson(request) as Record<string, unknown>), + authorization: request.headers.authorization, + url: request.url, + }); response.setHeader('Content-Type', 'application/json'); response.end( JSON.stringify({ diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 0df71a811..9c220b868 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -5,11 +5,62 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { + KIMI_CODE_PLUGIN_MARKETPLACE_URL, + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, +} from '#/constant/app'; +import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); +describe('computeUpdateStatus', () => { + it('reports not-installed when the plugin is absent', () => { + expect(computeUpdateStatus('1.0.0', undefined, false)).toEqual({ kind: 'not-installed' }); + }); + + it('reports an update when the marketplace version is newer', () => { + expect(computeUpdateStatus('5.1.0', '5.0.0', true)).toEqual({ + kind: 'update', + local: '5.0.0', + latest: '5.1.0', + }); + }); + + it('reports up-to-date when versions match', () => { + expect(computeUpdateStatus('5.1.0', '5.1.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.1.0', + }); + }); + + it('does not offer a downgrade when the local version is ahead', () => { + expect(computeUpdateStatus('3.1.1', '3.2.0', true)).toEqual({ + kind: 'up-to-date', + version: '3.2.0', + }); + }); + + it('never reports an update for non-semver versions', () => { + expect(computeUpdateStatus('latest', '5.0.0', true).kind).toBe('up-to-date'); + expect(computeUpdateStatus('5.1.0', 'dev', true).kind).toBe('up-to-date'); + }); + + it('shows the local version even when the marketplace omits one', () => { + expect(computeUpdateStatus(undefined, '5.0.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.0.0', + }); + }); + + it('does not claim the marketplace version as installed when the local version is unknown', () => { + // No spurious `installed · v<latest>`, and no permanent suppression of updates. + expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ + kind: 'up-to-date', + version: undefined, + }); + }); +}); + describe('loadPluginMarketplace', () => { it('loads a local marketplace file and resolves relative plugin sources', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); @@ -74,9 +125,22 @@ describe('loadPluginMarketplace', () => { }); it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; + }) as unknown as typeof fetch; const marketplace = await loadPluginMarketplace({ workDir: REPO_ROOT, source: join(REPO_ROOT, 'plugins/marketplace.json'), + fetchImpl, }); expect(marketplace.plugins).toContainEqual( @@ -84,7 +148,8 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', + version: '6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -131,6 +196,247 @@ describe('loadPluginMarketplace', () => { ); }); + it('falls back to the source checkout marketplace when the default CDN cannot be fetched', async () => { + const previous = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; + + try { + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + expect(marketplace.source).toBe(join(REPO_ROOT, 'plugins/marketplace.json')); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'superpowers', + source: 'https://github.com/obra/superpowers', + }), + ); + } finally { + if (previous === undefined) { + delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + } else { + process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] = previous; + } + } + }); + + it('does not use the source checkout fallback for explicit marketplace sources', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; + + await expect(loadPluginMarketplace({ + workDir: '/tmp/work', + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + fetchImpl, + })).rejects.toThrow(/fetch failed/); + }); + + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); + }); + + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); + }); + + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + }); + + it('accepts legacy marketplace type aliases as normal plugins', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'kimi-webbridge', + type: 'guide', + displayName: 'Kimi WebBridge', + source: './kimi-webbridge', + installSkill: 'install', + removeSkill: 'remove', + }, + { + id: 'demo-managed', + type: 'managed', + source: './demo-managed', + }, + ], + }), + 'utf8', + ); + + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file }); + + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'kimi-webbridge', + source: join(dir, 'kimi-webbridge'), + }), + ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'demo-managed', + source: join(dir, 'demo-managed'), + }), + ); + }); + + it('rejects an entry without a source', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'broken', displayName: 'Broken' }] }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /must define "source"/, + ); + }); + it('loads an explicit remote marketplace with injectable fetch', async () => { const source = 'https://example.com/plugins/marketplace.json'; const fetchImpl = vi.fn(async () => ({ @@ -179,4 +485,21 @@ describe('loadPluginMarketplace', () => { /"tier" must be one of/, ); }); + + it('rejects unknown marketplace entry types', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', type: 'integration', source: './demo' }], + }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /Legacy aliases "managed" and "guide" are also accepted/, + ); + }); + }); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index b986f08e6..353b10ee5 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,58 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('formats input tokens and cache read/write counts', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 700, + inputCacheRead: 1200, + inputCacheCreation: 100, + output: 200, + }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100', + ); + }); + + it('omits cache write count when it is zero', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 1000, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 200, + }, + }); + expect(result).toContain('tokens in 1.0k'); + expect(result).toContain('cache read 0 (0%)'); + expect(result).not.toContain('/ write 0'); + }); + + it('omits TPS when the streamed window is too short to measure', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 1200, + llmStreamDurationMs: 1, + usage: { output: 44 }, + }); + expect(result).toBe( + '[Debug] TTFT: 1.2s | 44 tokens in 1ms (stream too short for TPS)', + ); + }); + + it('computes TPS once the streamed window reaches the reliability threshold', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 200, + llmStreamDurationMs: 50, + usage: { output: 20 }, + }); + expect(result).toBe('[Debug] TTFT: 200ms | TPS: 400.0 tok/s (20 tokens in 50ms)'); + }); + it('formats durations under 1s as milliseconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 50, @@ -37,6 +89,52 @@ describe('formatStepDebugTiming', () => { expect(result).toContain('900ms'); }); + it('splits TTFT into api-server and client portions when both are present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 2500, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 2400, + llmRequestBuildMs: 100, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 2.5s (api 2.4s + client 100ms) | TPS: 40.0 tok/s (200 tokens in 5.0s)', + ); + }); + + it('falls back to the bare TTFT when only one split component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 700, + usage: { output: 0 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms'); + }); + + it('appends the decode wait/consume split to the TPS clause', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + llmClientConsumeMs: 400, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)', + ); + }); + + it('omits the decode split when only one component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + usage: { output: 200 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); + }); + it('formats durations at or above 1s as seconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1500, diff --git a/apps/kimi-code/tsconfig.dev.json b/apps/kimi-code/tsconfig.dev.json new file mode 100644 index 000000000..9e4df279a --- /dev/null +++ b/apps/kimi-code/tsconfig.dev.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts", + "../../packages/agent-core/src/prompt-modules.d.ts" + ] +} diff --git a/apps/kimi-code/tsconfig.json b/apps/kimi-code/tsconfig.json index ea6828176..10388dd08 100644 --- a/apps/kimi-code/tsconfig.json +++ b/apps/kimi-code/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, + "experimentalDecorators": true, "paths": { "@/*": ["./src/*"] } diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts index 918b62199..858aeeb48 100644 --- a/apps/kimi-code/tsdown.config.ts +++ b/apps/kimi-code/tsdown.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ format: ['esm'], outDir: 'dist', clean: true, + dts: false, + hash: false, banner: { js: [ '#!/usr/bin/env node', @@ -29,7 +31,10 @@ export default defineConfig({ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(), }, deps: { - alwaysBundle: [/^@moonshot-ai\//], - neverBundle: [], + onlyBundle: false, + }, + outputOptions: { + codeSplitting: false, + entryFileNames: 'main.mjs', }, }); diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index bf4e16fe9..c1008cb61 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -21,6 +21,9 @@ const optionalNativeDependencies = new Set(['cpu-features']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; if (optionalNativeDependencies.has(id)) return false; + // Everything else is force-bundled, which covers `@moonshot-ai/*` (incl. + // vis-server for `kimi vis`) plus its transitive `hono` / `@hono/node-server` + // — so the SEA bundle is self-contained (check-bundle.mjs enforces this). return true; } diff --git a/apps/kimi-code/vitest.config.ts b/apps/kimi-code/vitest.config.ts index 83bf97086..350417bc2 100644 --- a/apps/kimi-code/vitest.config.ts +++ b/apps/kimi-code/vitest.config.ts @@ -2,12 +2,9 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; -import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; - const appRoot = import.meta.dirname; export default defineConfig({ - plugins: [rawTextPlugin()], resolve: { alias: { '@': resolve(appRoot, 'src'), diff --git a/apps/kimi-desktop/.gitignore b/apps/kimi-desktop/.gitignore new file mode 100644 index 000000000..66de381f3 --- /dev/null +++ b/apps/kimi-desktop/.gitignore @@ -0,0 +1,3 @@ +out/ +dist-app/ +resources-stage/ diff --git a/apps/kimi-desktop/README.md b/apps/kimi-desktop/README.md new file mode 100644 index 000000000..32ca3ee64 --- /dev/null +++ b/apps/kimi-desktop/README.md @@ -0,0 +1,107 @@ +# Kimi Code Desktop + +An Electron desktop client for Kimi Code (product name **Kimi Code Desktop**; +workspace package `@moonshot-ai/kimi-desktop`). It is a thin **shell + process manager** +around the existing web UI (`apps/kimi-web`): it does not reimplement any UI or +backend, it just opens a native window onto the local Kimi server. + +## How it works + +The web UI cannot run on its own — it needs the Kimi Code **server** (REST + WS +under `/api/v1`). That server already ships as a self-contained single-file +executable (SEA) built from `apps/kimi-code`, with the web UI bundled inside it. + +On launch the app: + +1. Runs the bundled SEA's `server run`, which reuses a live shared daemon if one + is already running, or starts one — exactly the same `ensureDaemon` flow the + CLI (`kimi web`) uses. The daemon binds the well-known port (`58627`) and + writes `~/.kimi-code/server/lock`, so the CLI, the browser and the TUI all + share the **same** server. +2. Reads that lock file for the real port and loads the web UI from the daemon's + origin (e.g. `http://127.0.0.1:58627`) — same-origin, no CORS, no preload. + +On quit the daemon is **left running**; it self-exits ~60s after the last client +disconnects, so closing the desktop app never tears down a server another client +is still using. + +Key files: + +- `src/main/ensure-server.ts` — run the SEA, read the lock, confirm `/healthz`. +- `src/main/sea-path.ts` — resolve the bundled SEA path (dev vs packaged). +- `src/main/index.ts` — window, native menu, window-state, loading/error screens. + +## Develop + +The dev build loads the SEA from `apps/kimi-code/dist-native/bin/<target>/`, so +build the backend once for your platform first: + +```bash +# one-time (rebuild when kimi-code / kimi-web change): +pnpm --filter @moonshot-ai/kimi-web run build +node apps/kimi-code/scripts/copy-web-assets.mjs +pnpm --filter @moonshot-ai/kimi-code run build:native:sea + +# then run the desktop app (builds the main process, launches Electron): +pnpm -C apps/kimi-desktop run dev # or: pnpm dev:desktop (from repo root) +``` + +Checks: + +```bash +pnpm -C apps/kimi-desktop run typecheck +``` + +## Package + +`dist` builds the main process and runs electron-builder for the **current** +platform. `scripts/before-pack.cjs` stages the matching-platform SEA into the +app's resources (`<resources>/bin/<target>/`). + +```bash +# unsigned local build (for your own machine): +CSC_IDENTITY_AUTO_DISCOVERY=false pnpm -C apps/kimi-desktop run dist +# -> apps/kimi-desktop/dist-app/ +``` + +> Do **not** rename a built `.app` bundle — renaming invalidates its code +> signature and macOS will report it as "damaged". + +Cross-platform installers are produced in CI (`.github/workflows/desktop-build.yml`), +which builds the SEA on each platform runner and packages there. SEA injection +is per-platform (the blob is injected into the host Node binary), so each OS must +be built on its own runner. + +### macOS signing + notarization + +An **unsigned** macOS build shows *"app is damaged and can't be opened"* once it +has been transferred to another Mac (Gatekeeper quarantine). To distribute it, +the app must be signed with a **Developer ID Application** certificate and +notarized by Apple. The config (`electron-builder.config.cjs`) applies the +hardened runtime + entitlements (`build/entitlements.mac.plist`) to the app and +the nested SEA, and signing/notarization are environment-driven: + +```bash +KIMI_DESKTOP_NOTARIZE=true \ +CSC_NAME="Developer ID Application: … (TEAMID)" \ +APPLE_API_KEY=/path/AuthKey_XXX.p8 APPLE_API_KEY_ID=XXXX APPLE_API_ISSUER=…uuid… \ +pnpm -C apps/kimi-desktop run dist +``` + +In CI, run the **desktop-build** workflow with `sign-macos: true`; it reuses the +same Apple secrets / keychain action as the TUI native build +(`APPLE_CERTIFICATE_P12`, `APPLE_NOTARIZATION_KEY_*`). The resulting `.dmg` opens +on any Mac without warnings. + +> An `Apple Development` certificate is **not** enough — it can sign for your own +> machine but cannot be notarized. You need a `Developer ID Application` cert. + +## v1 scope / not done yet + +- **Auto-update**: not implemented (v2). +- **Windows / Linux signing**: unsigned in v1 (Windows shows a SmartScreen + prompt). Only macOS is signed + notarized. +- **App icon**: builds ship the Kimi logo (sourced from the docs site art) on + macOS, Windows, and Linux. +- **First launch may need network**: the SEA resolves its native sidecars + (clipboard / koffi) the same way the installed CLI does. diff --git a/apps/kimi-desktop/build/entitlements.mac.plist b/apps/kimi-desktop/build/entitlements.mac.plist new file mode 100644 index 000000000..949a28088 --- /dev/null +++ b/apps/kimi-desktop/build/entitlements.mac.plist @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <!-- Electron/V8 and the bundled Node SEA both need JIT pages. --> + <key>com.apple.security.cs.allow-jit</key> + <true/> + + <!-- V8 writes to executable memory during codegen. --> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + + <!-- The bundled server dlopen's koffi.node / clipboard.node at runtime; + they are third-party and not signed by our Team. Without this the + hardened runtime rejects them. (Same as the TUI's native build.) --> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> + + <!-- Some users set NODE_OPTIONS / DYLD_* envs; allow them. --> + <key>com.apple.security.cs.allow-dyld-environment-variables</key> + <true/> +</dict> +</plist> diff --git a/apps/kimi-desktop/build/icon.icns b/apps/kimi-desktop/build/icon.icns new file mode 100644 index 000000000..2c9de6605 Binary files /dev/null and b/apps/kimi-desktop/build/icon.icns differ diff --git a/apps/kimi-desktop/build/icon.ico b/apps/kimi-desktop/build/icon.ico new file mode 100644 index 000000000..c4c168ba1 Binary files /dev/null and b/apps/kimi-desktop/build/icon.ico differ diff --git a/apps/kimi-desktop/build/icon.png b/apps/kimi-desktop/build/icon.png new file mode 100644 index 000000000..5b41bb609 Binary files /dev/null and b/apps/kimi-desktop/build/icon.png differ diff --git a/apps/kimi-desktop/electron-builder.config.cjs b/apps/kimi-desktop/electron-builder.config.cjs new file mode 100644 index 000000000..a0e80ee4b --- /dev/null +++ b/apps/kimi-desktop/electron-builder.config.cjs @@ -0,0 +1,80 @@ +'use strict'; + +// electron-builder configuration. +// +// Signing / notarization are environment-driven so the same config produces +// either an unsigned local build or a fully signed + notarized distributable: +// +// unsigned (default / local): +// CSC_IDENTITY_AUTO_DISCOVERY=false -> no signing, no notarization +// +// signed + notarized (CI, with a Developer ID cert in the keychain): +// KIMI_DESKTOP_NOTARIZE=true +// APPLE_API_KEY=<path to .p8> APPLE_API_KEY_ID=<id> APPLE_API_ISSUER=<id> +// +// The entitlements (hardened runtime) are applied to the app AND every nested +// Mach-O — including the bundled Kimi SEA backend — via entitlementsInherit, so +// the whole bundle passes notarization. Mirrors the TUI's native entitlements. + +const notarize = process.env.KIMI_DESKTOP_NOTARIZE === 'true'; + +// Internal-testing artifact name: +// KCD-beta-alpha-crazy-internal-v50-<arch>-<MMDD>.<ext> +// The date is MMDD in UTC+8, computed at build time. `v50` is a fixed label +// (not a version number) — edit it here to bump the internal build label. +function mmddUTC8() { + const utc8 = new Date(Date.now() + 8 * 60 * 60 * 1000); + const mm = String(utc8.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(utc8.getUTCDate()).padStart(2, '0'); + return mm + dd; +} +const artifactName = 'KCD-beta-alpha-crazy-internal-v50-${arch}-' + mmddUTC8() + '.${ext}'; + +module.exports = { + appId: 'ai.moonshot.kimi.desktop', + productName: 'Kimi Code Desktop', + copyright: 'Copyright © Moonshot AI', + + directories: { + output: 'dist-app', + }, + + // No native node modules in the Electron app itself; the backend is the + // prebuilt SEA staged by before-pack.cjs. + npmRebuild: false, + asar: true, + + files: ['out/**', 'package.json'], + + beforePack: './scripts/before-pack.cjs', + extraResources: [{ from: 'resources-stage/bin', to: 'bin' }], + + mac: { + category: 'public.app-category.developer-tools', + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.plist', + target: ['dmg', 'zip'], + artifactName, + notarize, + }, + + win: { + target: ['nsis'], + artifactName, + }, + + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + }, + + linux: { + category: 'Development', + target: ['AppImage', 'deb'], + artifactName, + maintainer: 'Moonshot AI', + }, +}; diff --git a/apps/kimi-desktop/package.json b/apps/kimi-desktop/package.json new file mode 100644 index 000000000..6501c6ea6 --- /dev/null +++ b/apps/kimi-desktop/package.json @@ -0,0 +1,24 @@ +{ + "name": "@moonshot-ai/kimi-desktop", + "version": "0.1.1-internal.0", + "private": true, + "license": "MIT", + "description": "Kimi Code desktop client — an Electron shell around the Kimi web UI.", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code", + "type": "module", + "main": "out/main.cjs", + "scripts": { + "build": "tsdown", + "start": "electron .", + "dev": "tsdown && electron .", + "typecheck": "tsc --noEmit", + "dist": "tsdown && electron-builder --config electron-builder.config.cjs" + }, + "devDependencies": { + "electron": "33.4.11", + "electron-builder": "25.1.8", + "tsdown": "0.22.0", + "typescript": "6.0.2" + } +} diff --git a/apps/kimi-desktop/scripts/before-pack.cjs b/apps/kimi-desktop/scripts/before-pack.cjs new file mode 100644 index 000000000..3b28402ed --- /dev/null +++ b/apps/kimi-desktop/scripts/before-pack.cjs @@ -0,0 +1,42 @@ +'use strict'; + +// electron-builder `beforePack` hook. +// +// Each electron-builder run targets one (platform, arch). We stage the matching +// prebuilt Kimi SEA backend into `resources-stage/bin/<target>/` so that the +// `extraResources` rule copies exactly that one binary into the packaged app's +// resources. sea-path.ts resolves `<resources>/bin/<target>/kimi[.exe]` at +// runtime, where <target> is `${process.platform}-${process.arch}`. + +const { existsSync, rmSync, mkdirSync, cpSync } = require('node:fs'); +const { join, resolve } = require('node:path'); + +// electron-builder Arch enum -> Node `process.arch` name. +const ARCH_NAMES = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }; + +exports.default = async function beforePack(context) { + const platform = context.electronPlatformName; // 'darwin' | 'win32' | 'linux' + const archName = ARCH_NAMES[context.arch]; + if (archName === undefined) { + throw new Error(`Unsupported arch for packaging: ${String(context.arch)}`); + } + const target = `${platform}-${archName}`; + const exe = platform === 'win32' ? 'kimi.exe' : 'kimi'; + + const desktopRoot = resolve(__dirname, '..'); + const seaDir = resolve(desktopRoot, '..', 'kimi-code', 'dist-native', 'bin', target); + const seaExe = join(seaDir, exe); + if (!existsSync(seaExe)) { + throw new Error( + `Bundled Kimi server not found for ${target} at ${seaExe}. ` + + `Build it for this platform first: \`pnpm -C apps/kimi-code build:native:sea\` ` + + `(CI builds the SEA on each platform runner before packaging).`, + ); + } + + const stageDir = resolve(desktopRoot, 'resources-stage', 'bin', target); + rmSync(resolve(desktopRoot, 'resources-stage'), { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + cpSync(seaDir, stageDir, { recursive: true }); + console.log(`[before-pack] staged Kimi server (${target}) -> ${stageDir}`); +}; diff --git a/apps/kimi-desktop/src/main/ensure-server.ts b/apps/kimi-desktop/src/main/ensure-server.ts new file mode 100644 index 000000000..075ae3606 --- /dev/null +++ b/apps/kimi-desktop/src/main/ensure-server.ts @@ -0,0 +1,129 @@ +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Overall budget for the bundled `kimi server run` to finish ensuring a daemon. */ +const RUN_TIMEOUT_MS = 30_000; +/** How long to keep polling `/healthz` before declaring the daemon unhealthy. */ +const HEALTH_TIMEOUT_MS = 20_000; +const HEALTH_POLL_MS = 200; + +/** Subset of the server lock JSON we read (apps/kimi-code writes the full shape). */ +interface LockContents { + pid: number; + host?: string; + port: number; +} + +/** `<KIMI_CODE_HOME>` or `~/.kimi-code` — must match the server's `resolveKimiHome`. */ +export function kimiHome(): string { + const override = process.env['KIMI_CODE_HOME']; + if (override !== undefined && override.trim().length > 0) { + return override; + } + return join(homedir(), '.kimi-code'); +} + +function lockPath(): string { + return join(kimiHome(), 'server', 'lock'); +} + +/** Background daemon log written by the SEA — surfaced in the error screen / menu. */ +export function serverLogPath(): string { + return join(kimiHome(), 'server', 'server.log'); +} + +function readLock(): LockContents | null { + try { + const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial<LockContents>; + if (typeof parsed.port === 'number' && typeof parsed.pid === 'number') { + return { + pid: parsed.pid, + port: parsed.port, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + }; + } + return null; + } catch { + return null; + } +} + +function originFromLock(lock: LockContents): string { + const host = lock.host !== undefined && lock.host !== '0.0.0.0' ? lock.host : '127.0.0.1'; + return `http://${host}:${lock.port}`; +} + +async function isHealthy(origin: string, timeoutMs: number): Promise<boolean> { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const res = await fetch(`${origin}/api/v1/healthz`, { signal: controller.signal }); + if (!res.ok) { + return false; + } + const body = (await res.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Run the bundled SEA's `server run`, which reuses a live shared daemon or + * spawns one and exits once it is healthy. All discovery / port / lock logic + * lives in apps/kimi-code's `ensureDaemon`; we do not reimplement it. + */ +function runServerRun(seaPath: string): Promise<void> { + return new Promise((resolve, reject) => { + execFile( + seaPath, + ['server', 'run', '--log-level', 'error'], + { timeout: RUN_TIMEOUT_MS }, + (error, _stdout, stderr) => { + if (error) { + reject(new Error(`kimi server run failed: ${error.message}\n${stderr}`.trim())); + return; + } + resolve(); + }, + ); + }); +} + +export interface EnsureServerResult { + origin: string; +} + +/** + * Ensure the shared kimi-code daemon is running and return its origin. + * + * The desktop app participates in the same local-server ecosystem as the CLI, + * the browser and the TUI: it reuses a running daemon or starts one that the + * others can reuse — never a private, app-only server. + */ +export async function ensureServer(seaPath: string): Promise<EnsureServerResult> { + await runServerRun(seaPath); + + const lock = readLock(); + if (lock === null) { + throw new Error(`Kimi server lock not found at ${lockPath()} after starting the server.`); + } + const origin = originFromLock(lock); + + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isHealthy(origin, 500)) { + return { origin }; + } + await new Promise((resolve) => { + setTimeout(resolve, HEALTH_POLL_MS); + }); + } + throw new Error(`Kimi server at ${origin} did not become healthy within ${HEALTH_TIMEOUT_MS}ms.`); +} diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts new file mode 100644 index 000000000..2a765274b --- /dev/null +++ b/apps/kimi-desktop/src/main/index.ts @@ -0,0 +1,316 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; + +import { ensureServer, kimiHome, serverLogPath } from './ensure-server'; +import { resolveSeaPath } from './sea-path'; + +let mainWindow: BrowserWindow | null = null; + +// --- window state persistence ------------------------------------------------- + +interface WindowBounds { + width: number; + height: number; + x?: number; + y?: number; +} + +const DEFAULT_BOUNDS: WindowBounds = { width: 1280, height: 860 }; + +function stateFile(): string { + return join(app.getPath('userData'), 'window-state.json'); +} + +function loadBounds(): WindowBounds { + try { + const parsed = JSON.parse(readFileSync(stateFile(), 'utf-8')) as Partial<WindowBounds>; + if (typeof parsed.width === 'number' && typeof parsed.height === 'number') { + return { + width: parsed.width, + height: parsed.height, + x: typeof parsed.x === 'number' ? parsed.x : undefined, + y: typeof parsed.y === 'number' ? parsed.y : undefined, + }; + } + } catch { + // No saved state yet, or it is unreadable — fall back to defaults. + } + return DEFAULT_BOUNDS; +} + +function saveBounds(win: BrowserWindow): void { + try { + const bounds = win.getBounds(); + mkdirSync(dirname(stateFile()), { recursive: true }); + writeFileSync( + stateFile(), + JSON.stringify({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }), + ); + } catch { + // Best-effort; losing window position is not worth surfacing an error. + } +} + +// --- startup screens (no separate renderer files; inline data URLs) ----------- + +function dataUrl(html: string): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +const SCREEN_STYLE = ` + <style> + html, body { height: 100%; margin: 0; } + body { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 18px; background: #0b0b0c; color: #e7e7ea; font: 14px/1.5 system-ui, sans-serif; + -webkit-user-select: none; user-select: none; text-align: center; padding: 0 32px; + } + .spinner { + width: 34px; height: 34px; border-radius: 50%; + border: 3px solid #2a2a2e; border-top-color: #7c8cff; animation: spin 0.9s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + h1 { font-size: 15px; font-weight: 600; margin: 0; } + p { margin: 0; color: #9a9aa2; max-width: 560px; } + code { color: #c8c8d0; word-break: break-all; } + </style> +`; + +function loadingHtml(): string { + return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE} + <div class="spinner"></div> + <h1>正在启动 Kimi 本地服务…</h1> + <p>首次启动可能需要几秒。</p>`; +} + +function errorHtml(message: string): string { + const safe = message.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE} + <h1>无法启动本地服务</h1> + <p>${safe}</p> + <p>查看日志:<code>${serverLogPath()}</code></p> + <p>菜单 → Kimi Code Desktop → 重试连接,或先检查日志。</p>`; +} + +// --- server auth token -------------------------------------------------------- + +/** On-disk filename of the daemon's persistent bearer token (under KIMI_CODE_HOME). */ +const SERVER_TOKEN_FILE = 'server.token'; + +/** + * Read the daemon's bearer token so the web UI can authenticate without showing + * the manual token dialog on a fresh launch. Returns undefined when the token + * cannot be read (the web UI then falls back to the dialog). + */ +function readServerToken(): string | undefined { + try { + const token = readFileSync(join(kimiHome(), SERVER_TOKEN_FILE), 'utf-8').trim(); + return token.length > 0 ? token : undefined; + } catch { + return undefined; + } +} + +// --- connect flow ------------------------------------------------------------- + +async function connect(win: BrowserWindow): Promise<void> { + await win.loadURL(dataUrl(loadingHtml())); + try { + const { origin } = await ensureServer(resolveSeaPath()); + process.stdout.write(`[kimi-desktop] connected to ${origin}\n`); + if (!win.isDestroyed()) { + // Append a desktop marker so the web UI shows the internal-build banner + // even when it is served by an already-running shared daemon (the desktop + // reuses the local daemon rather than starting a private one). Carry the + // server token in the `#token=` fragment — like `kimi web` does — so the + // web UI can authenticate without falling into the manual token dialog on + // a fresh launch. + const token = readServerToken(); + const fragment = token === undefined ? '' : `#token=${encodeURIComponent(token)}`; + await win.loadURL( + `${origin}/?kimi_desktop=1&platform=${process.platform}${fragment}`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[kimi-desktop] ensureServer failed: ${message}\n`); + if (!win.isDestroyed()) { + await win.loadURL(dataUrl(errorHtml(message))); + } + } +} + +function createWindow(): void { + const win = new BrowserWindow({ + ...loadBounds(), + minWidth: 720, + minHeight: 480, + backgroundColor: '#0b0b0c', + title: 'Kimi Code Desktop', + // macOS: hide the native title bar and float the traffic lights over the + // content; the web UI reserves a draggable strip at the top to clear them. + // 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights + // to the vertical center of the web UI's 48px header row (y 18 + 12px + // button height / 2 = 24 = the header's midline — same line as the + // sidebar-expand button and the conversation title). + // 'default' on other platforms (they keep their native title bar). + titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', + trafficLightPosition: { x: 16, y: 18 }, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + mainWindow = win; + // Keep the window title as the product name. The web page sets document.title + // ("Kimi Code Web"), which would otherwise replace it. + win.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + }); + // macOS traffic lights. + // + // 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom + // trafficLightPosition, the buttons can vanish (or lose their custom + // position) after a full-screen round-trip or on re-focus. Re-assert both + // on those transitions (observed on Electron 33; belt-and-braces). + // + // 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by + // AppKit, and the dimmed color follows the WINDOW appearance, not the + // page (electron#27295) — with the OS in dark mode but the web UI on a + // light theme, the light-gray dimmed dots become invisible against the + // light sidebar. That is fixed by the theme sync below, which keeps the + // window appearance aligned with the web UI's <html data-color-scheme>. + if (process.platform === 'darwin') { + const showTrafficLights = (): void => { + if (win.isDestroyed()) return; + win.setWindowButtonPosition({ x: 16, y: 18 }); + win.setWindowButtonVisibility(true); + }; + win.on('enter-full-screen', showTrafficLights); + win.on('leave-full-screen', showTrafficLights); + win.on('focus', showTrafficLights); + + // Theme sync: no preload/IPC channel exists, so inject a tiny observer + // that reports <html data-color-scheme> ('light' | 'dark' | 'system') + // through a tagged console message, and mirror it into + // nativeTheme.themeSource (same three states). The startup/error screens + // (data: URLs) have no such attribute and harmlessly report 'system'. + const THEME_TAG = '__kimi_desktop_theme__:'; + win.webContents.on('console-message', (_event, _level, message) => { + if (!message.startsWith(THEME_TAG)) return; + const scheme = message.slice(THEME_TAG.length); + if (scheme === 'light' || scheme === 'dark' || scheme === 'system') { + nativeTheme.themeSource = scheme; + } + }); + win.webContents.on('did-finish-load', () => { + win.webContents + .executeJavaScript( + `(() => { + const report = () => { + const v = document.documentElement.dataset.colorScheme; + console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system')); + }; + new MutationObserver(report).observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-color-scheme'], + }); + report(); + })();`, + ) + .catch(() => { + // Navigation can tear the page down mid-injection; theme sync is + // cosmetic, so ignore. + }); + }); + } + win.on('close', () => { + saveBounds(win); + }); + win.on('closed', () => { + if (mainWindow === win) { + mainWindow = null; + } + }); + void connect(win); +} + +// --- native menu -------------------------------------------------------------- + +function buildMenu(): void { + const isMac = process.platform === 'darwin'; + const appMenu: MenuItemConstructorOptions = { + label: 'Kimi Code Desktop', + submenu: [ + ...(isMac ? [{ role: 'about' as const }, { type: 'separator' as const }] : []), + { + label: '重试连接', + click: () => { + if (mainWindow !== null) { + void connect(mainWindow); + } else { + createWindow(); + } + }, + }, + { + label: '打开服务日志', + click: () => { + void shell.openPath(serverLogPath()); + }, + }, + { type: 'separator' }, + isMac ? { role: 'quit' } : { role: 'close' }, + ], + }; + + const template: MenuItemConstructorOptions[] = [ + appMenu, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +// --- app lifecycle ------------------------------------------------------------ + +function main(): void { + // The shared daemon is deliberately left running on quit — it self-exits ~60s + // after the last client disconnects, so we never tear down a server another + // client (CLI / browser / TUI) may still be using. + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + void app.whenReady().then(() => { + buildMenu(); + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + }); +} + +main(); diff --git a/apps/kimi-desktop/src/main/sea-path.ts b/apps/kimi-desktop/src/main/sea-path.ts new file mode 100644 index 000000000..8703d72b1 --- /dev/null +++ b/apps/kimi-desktop/src/main/sea-path.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; + +import { app } from 'electron'; + +// The bundled backend targets the same 6 platform/arch pairs the kimi-code +// native SEA build supports (apps/kimi-code/scripts/native/native-deps.mjs). +const SUPPORTED_TARGETS = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]); + +/** `<platform>-<arch>` triple for the current process, validated against the SEA targets. */ +export function currentTarget(): string { + const target = `${process.platform}-${process.arch}`; + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`No bundled Kimi server for this platform: ${target}`); + } + return target; +} + +function executableName(): string { + return process.platform === 'win32' ? 'kimi.exe' : 'kimi'; +} + +/** + * Absolute path to the bundled SEA backend executable. + * + * - packaged: `<resources>/bin/<target>/kimi[.exe]` — placed there by + * electron-builder `extraResources`. + * - dev: `apps/kimi-code/dist-native/bin/<target>/kimi[.exe]` — produced by + * `pnpm -C apps/kimi-code build:native:sea`. In dev `app.getAppPath()` is + * `apps/kimi-desktop`, so the sibling app is one level up. + */ +export function resolveSeaPath(): string { + const target = currentTarget(); + const exe = executableName(); + if (app.isPackaged) { + return join(process.resourcesPath, 'bin', target, exe); + } + return join(app.getAppPath(), '..', 'kimi-code', 'dist-native', 'bin', target, exe); +} diff --git a/apps/kimi-desktop/tsconfig.json b/apps/kimi-desktop/tsconfig.json new file mode 100644 index 000000000..596e2cf72 --- /dev/null +++ b/apps/kimi-desktop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/apps/kimi-desktop/tsdown.config.ts b/apps/kimi-desktop/tsdown.config.ts new file mode 100644 index 000000000..c58e8866d --- /dev/null +++ b/apps/kimi-desktop/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +// The Electron main process is loaded as CommonJS (`out/main.cjs`). All sources +// under src/main are bundled into a single file; `electron` stays external +// (provided by the Electron runtime) and Node built-ins are external by default. +export default defineConfig({ + entry: { main: 'src/main/index.ts' }, + format: ['cjs'], + platform: 'node', + target: 'node20', + outDir: 'out', + clean: true, + dts: false, + fixedExtension: true, + deps: { neverBundle: ['electron'] }, +}); diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md new file mode 100644 index 000000000..19180fe1c --- /dev/null +++ b/apps/kimi-web/AGENTS.md @@ -0,0 +1,61 @@ +# kimi-web Agent Guide + +Package-local rules for `apps/kimi-web` (`@moonshot-ai/kimi-web`). + +## What it is + +The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It talks to the local server over REST + WebSocket under `/api/v1`. Stack: Vue 3 + Vite 6 + TypeScript (strict) + vue-i18n v11. (Tailwind was removed; all styling is via design tokens in `src/style.css` + scoped component styles.) There is no client router and no Pinia; state lives in composables/refs and provide/inject. + +## Design system (normative — required when modifying the UI) + +- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it. +- **Use the primitives in `src/components/ui/`.** The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, MoonSpinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks). +- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`. A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-bp-sm/-md`. +- **The moon spinner (🌑…🌘) is reserved** for the chat "waiting for the agent's first response" state only, and is rendered solely by `ui/MoonSpinner.vue`; every other loading state uses the plain `Spinner`. +- **Run `pnpm --filter @moonshot-ai/kimi-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost`, no-emoji-icon except moon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations. +- **Verify visually.** For any UI change, render it in the browser (light + dark, plus hover/focus states) and confirm it matches the design-system view and introduces no regression before considering it done. Build/typecheck/check-style are necessary but not sufficient. + +## Layout (`src/`) + +- `main.ts` — bootstrap (creates the app, installs i18n, mounts `#app`). `App.vue` — root component, holds most app state. +- `api/` — server client. `index.ts` exposes the `getKimiWebApi()` singleton; `config.ts` builds REST/WS URLs; `daemon/` holds the wire client (`http.ts`, `ws.ts`, `wire.ts`, `mappers.ts`, `agentEventProjector.ts`, `eventReducer.ts`). +- `components/` — SFCs grouped by area: `chat/` (conversation/chat UI), `settings/` (settings & configuration), `dialogs/` (modal dialogs & sheets), `mobile/` (mobile-specific shell), `ui/` (design-system primitives — see "Design system" above), plus shared layout components at the top level. +- `composables/` — reusable state logic, `useX` naming (`useKimiWebClient`, `useIsDark`, `usePaneLayout`, …). +- `lib/` — pure helpers (`parseDiff`, `slashCommands`, `sessionRoute`, `toolMeta`, …). +- `i18n/` — vue-i18n setup plus locale namespaces. +- `debug/` — `DebugPanel.vue` and `trace.ts` for client error/trace capture. + +## Vue conventions (normative) + +- SFCs use **`<script setup lang="ts">`** + the Composition API. Component files are **PascalCase** (`ChatHeader.vue`). +- Type props with the generic form `defineProps<{ ... }>()`; type emits with `defineEmits<{ evt: [arg: Type] }>()`. +- Shared components go in `src/components/`; reusable logic goes in `src/composables/` with a `use` prefix. +- There is **no auto-import plugin** and **no path alias** — `#/` and `@/` are intentionally unused. Write relative imports (`../i18n`, `./config`). + +## i18n (normative — keeping locales in sync is manual) + +- Setup: `src/i18n/index.ts`, vue-i18n in Composition mode (`legacy: false`), fallback `en`. The active locale is persisted in `localStorage` under `kimi-locale`. +- Locale files: `src/i18n/locales/{en,zh}/<namespace>.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. +- Reference with `const { t } = useI18n()` and `t('namespace.key')` (same form in templates). +- **Adding a key:** add it to **both** `en/<ns>.ts` and `zh/<ns>.ts`. **Adding a namespace:** create the file in both locales **and** register it in `locales/index.ts`. +- There is **no automated missing-key or en/zh parity check**. Keeping the two locales in sync is a manual responsibility — do not leave a key present in only one locale. + +## Commands + +All via `pnpm --filter @moonshot-ai/kimi-web …`: + +- `dev` — Vite dev server (port `WEB_PORT`, default 5175; proxies `/api/v1` to `KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). +- `dev:stub` — offline stub daemon (`dev/stub-daemon.mjs`). +- `build` — production build into `dist/`. +- `typecheck` — `vue-tsc --noEmit`. +- `test` — `vitest run` (pure logic tests only; no jsdom / component tests). +- `check:style` — design-system §06 anti-pattern guard (`scripts/check-style.mjs`). +- There is **no `lint` script** in this package; linting runs at the repo root via oxlint. + +## Gotchas / hard rules + +- **Do not depend on `@moonshot-ai/agent-core`** (mirrors the CLI/SDK rule). The web app is decoupled from core/protocol; wire types are re-implemented locally in `src/api/daemon/wire.ts`. Keep it that way. +- **Same-origin by default:** the browser only talks to its own origin; Vite proxies `/api/v1` for both HTTP and WS. Set `VITE_KIMI_SERVER_HTTP_URL` only when you intentionally want direct (CORS) mode. +- Vite-injected globals (`__KIMI_DEV_PROXY_TARGET__`, `__KIMI_WEB_VERSION__`, `__KIMI_WEB_COMMIT__`) are declared in `src/env.d.ts` and defined in `vite.config.ts`. Do not hand-edit `dist/`. +- **Theming:** the root element carries `data-color-scheme` (`light` | `dark` | `system`); react to it through `useIsDark()`, not by reading the DOM directly. +- Keep the Vite **dev** proxy and **`preview`** proxy in sync — both are defined in `vite.config.ts`. diff --git a/apps/kimi-web/CHANGELOG.md b/apps/kimi-web/CHANGELOG.md new file mode 100644 index 000000000..0f53dc5c4 --- /dev/null +++ b/apps/kimi-web/CHANGELOG.md @@ -0,0 +1,7 @@ +# @moonshot-ai/kimi-web + +## 0.1.2 + +### Patch Changes + +- [#1085](https://github.com/MoonshotAI/kimi-code/pull/1085) [`f1fad72`](https://github.com/MoonshotAI/kimi-code/commit/f1fad7222ccd3f66c1cae6c5b9c009230227cd2f) - Fix stuttery streaming in the web chat by coalescing rapid token updates into a single render per frame. diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md new file mode 100644 index 000000000..bc4aca3f3 --- /dev/null +++ b/apps/kimi-web/README.md @@ -0,0 +1,124 @@ +# Kimi Web + +A browser client for Kimi Code — a peer to the TUI (`apps/kimi-code`) that talks +to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript. + +--- + +## Quick start + +```bash +# 1) Against a REAL server (the server must be running and reachable) +WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:58627 pnpm -C apps/kimi-web run dev +# …or from the repo root: pnpm dev:web (uses the defaults below) + +# 2) Offline / no server — a stub that fakes the server API + event stream +pnpm -C apps/kimi-web run dev:stub # then run dev in another shell + +# checks +pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit +pnpm -C apps/kimi-web run test # vitest (pure logic only) +pnpm -C apps/kimi-web run build # vite build +``` + +### How it connects to the server + +The browser cannot reach the server cross-origin (no CORS), so Vite **same-origin +proxies** `/api/v1` (HTTP + WS) to the server (`vite.config.ts`): + +| env var | default | meaning | +| ----------------- | ------------------------ | ---------------------------------------- | +| `WEB_PORT` | `5175` | port the dev server listens on | +| `KIMI_SERVER_URL` | `http://127.0.0.1:58627` | where `/api/v1` (and `/api/v1/ws`) is forwarded | + +> Behind a corporate HTTP proxy, also set `NO_PROXY=<server-host>` (for example, +> `NO_PROXY=127.0.0.1,localhost`) so the proxy forward reaches the server directly. + +--- + +## Architecture + +A strict one-direction data flow; components never touch the network or the +reducer — they consume computed view props and call actions. + +``` +server (REST + WS) + └─ src/api/daemon/client.ts REST adapter (envelope → AppX types) + └─ src/api/daemon/ws.ts WS frames → classify → projector/reducer + └─ agentEventProjector.ts RAW agent-core events → AppEvent[] + └─ eventReducer.ts AppEvent[] → state + └─ src/composables/useKimiWebClient.ts the ONLY place that imports api + state; + exposes computed view props + actions + └─ src/components/*.vue render props, emit intents (no api access) +``` + +> The directory name `src/api/daemon/` is historical and kept to minimise +> diff churn; conceptually it is the **server** adapter. + +- **Adapter** (`src/api/`): wire types are snake_case; `AppX` types are camelCase. + `config.ts` builds `/api/v1` URLs. +- **Event projector** (`agentEventProjector.ts`): the server streams **raw + agent-core events** (no `event.` prefix). `classifyFrame` routes raw vs + protocol (`event.*`) frames; the projector converts them to `AppEvent`s. +- **i18n** (`src/i18n/`): vue-i18n, en/zh, per-namespace flat camelCase keys. + Detect order: `localStorage('kimi-locale')` → `navigator.language` → `en`. +--- + +## Server contract — non-obvious notes + +The server's wire protocol has a few things that will bite you if forgotten: + +- **Envelope:** every response is `{ code, msg, data, request_id }` and the HTTP + status is **always 200** — check `code` (0 = ok), not the status. +- **Prompts require five fields.** `POST /sessions/{id}/prompts` must carry + `{ content, model, thinking, permission_mode, plan_mode }`. The web fills these + from settings (model ← session/`default_model`, thinking/permission/plan ← the + StatusLine controls). Sending only `{ content }` → `40001 model …`. +- **Creating a session needs a *registered* workspace.** `workspace_id` must be a + `wd_<slug>_<hash>` id that exists in the server's registry. Sessions get one + auto-assigned by cwd, but it isn't *registered* until you `POST /workspaces + { root }` (idempotent). The web registers on demand before `createSession` + (otherwise: `workspace not found: wd_…`). +- **Persisted sessions are directly promptable** — selecting an old session and + sending a message just works; there is **no `:activate` step**. +- **Workspaces** = real folders. `GET/POST/PATCH/DELETE /workspaces`, + `GET /fs:browse?path=`, `GET /fs:home` back the rail + folder picker. + +## Release & deployment + +Kimi Web is **not published as a standalone package**. It ships as the built-in +web UI of the `kimi` CLI (`apps/kimi-code`). + +### Current release flow + +1. **Develop** — `pnpm dev:web` (or `pnpm -C apps/kimi-web run dev`). +2. **Build** — `pnpm -C apps/kimi-web run build` produces `apps/kimi-web/dist`. +3. **Bundle into CLI** — `pnpm -C apps/kimi-code run build` runs + `scripts/copy-web-assets.mjs`, which copies `apps/kimi-web/dist` into + `apps/kimi-code/dist-web`. +4. **Publish** — the root `.github/workflows/release.yml` publishes + `@moonshot-ai/kimi-code` to npm; `dist-web` is listed in the package `files` + array, so the built web assets travel with the CLI package. +5. **Serve** — `kimi server run` / `kimi web` serves `dist-web` from the + installed package. + +The web UI does not display its own package version or build commit. It is +bundled into the CLI package and follows the published `@moonshot-ai/kimi-code` +release. + +### Suggested improvements + +- **Keep the current coupling for now.** Because Kimi Code is primarily a local + CLI/server product, bundling the web UI into the CLI package keeps installs + self-contained and avoids cross-origin/CORS complexity. +- **Add an independent web-deploy workflow only when needed.** If a public + standalone web deployment is required later, create + `.github/workflows/web-deploy.yml` that builds `apps/kimi-web` and uploads + `dist/` to the chosen static host (S3/CloudFront, Cloudflare Pages, Vercel, + etc.). Until then, do not maintain a separate deploy target. +- **Keep versioning owned by the CLI release.** `apps/kimi-web/package.json` + remains internal workspace metadata; do not surface it as a separate user + version unless the web app becomes an independently published product. +- **Ensure the web build is exercised in CI.** The root `build` script already + builds every workspace, so `pnpm run build` in CI covers `apps/kimi-web`. + Keep it that way; do not bypass the web build in release pipelines. diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html new file mode 100644 index 000000000..dff0375e4 --- /dev/null +++ b/apps/kimi-web/index.html @@ -0,0 +1,31 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" sizes="64x64" /> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" /> + <meta name="color-scheme" content="light dark" /> + <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" /> + <!-- Apply persisted display prefs BEFORE the bundle loads: without this, + users can get a color-scheme/font flash before useKimiWebClient mirrors + the data attributes. Mirrors applyColorSchemeToDocument. --> + <script> + (function () { + try { + var v = localStorage.getItem('kimi-web.color-scheme'); + if (v === 'light' || v === 'dark' || v === 'system') { + document.documentElement.dataset.colorScheme = v; + } + } catch (e) { + /* ignore */ + } + })(); + </script> + <title>Kimi Code Web + + +
+ + + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json new file mode 100644 index 000000000..300d1848d --- /dev/null +++ b/apps/kimi-web/package.json @@ -0,0 +1,40 @@ +{ + "name": "@moonshot-ai/kimi-web", + "version": "0.1.2", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "dev:stub": "node dev/stub-daemon.mjs", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "check:style": "node scripts/check-style.mjs" + }, + "dependencies": { + "@chenglou/pretext": "0.0.8", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "katex": "^0.17.0", + "markstream-vue": "^1.0.4", + "mermaid": "^11.15.0", + "shiki": "^4.3.0", + "stream-markdown": "^0.0.16", + "vue": "^3.5.35", + "vue-i18n": "^11.4.5" + }, + "devDependencies": { + "@iconify-json/ri": "^1.2.10", + "@iconify-json/tabler": "^1.2.35", + "@vitejs/plugin-vue": "^5.2.4", + "typescript": "6.0.2", + "unplugin-icons": "^23.0.0", + "vite": "^6.3.3", + "vitest": "4.1.4", + "vue-tsc": "~3.2.0", + "ws": "^8.18.0" + } +} diff --git a/apps/kimi-web/public/favicon.ico b/apps/kimi-web/public/favicon.ico new file mode 100644 index 000000000..9b4870b72 Binary files /dev/null and b/apps/kimi-web/public/favicon.ico differ diff --git a/apps/kimi-web/scripts/check-style.mjs b/apps/kimi-web/scripts/check-style.mjs new file mode 100644 index 000000000..81480efe9 --- /dev/null +++ b/apps/kimi-web/scripts/check-style.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +// check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web. +// +// Scans src/** for the rules in the design system (§06 of the DesignSystemView spec): +// no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow, +// icon-from-registry (hand-written ; Icon/Spinner/MoonSpinner + the +// 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt), +// no-hardcoded-hex (DiffView/DiffLines/Terminal domain colors + var() +// fallbacks exempt), no-hardcoded-font (token definitions exempt), +// radius-from-scale, z-from-scale, weight-from-scale. +// +// Default mode: report a baseline and exit 0 (warnings only). Pass --strict +// to exit 1 when any finding exists (flipped on in P3 enforcement). + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'src'); +const STRICT = process.argv.includes('--strict'); + +const DOMAIN_HEX_EXEMPT = new Set([ + 'chat/DiffView.vue', + 'chat/DiffLines.vue', + 'Terminal.vue', +]); + +// Files that legitimately render their own : bespoke data-viz / colored +// illustrations, the spinner, and brand marks (the Kimi wordmark on the loading +// screen). Everything else should use lib/icons.ts via /iconSvg(). The +// 32x22 Kimi eye logo is also exempted inline (matched by viewBox). The icon +// primitive (components/ui/Icon.vue) itself renders no hand-written , so it +// is not exempted here. +const ICON_EXEMPT = new Set([ + 'components/ui/Spinner.vue', + 'components/ui/MoonSpinner.vue', + 'components/ui/ContextRing.vue', + 'components/ui/AuthStateIcon.vue', + 'components/GlobalLoading.vue', +]); + +// Files entirely exempt from the §06 scan. The design-system showcase view is +// documentation/demo CSS (forced-dark previews, syntax-highlighting palettes, +// illustrative mockups) rather than product UI, so the anti-pattern rules do not +// apply to it. +const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']); + +const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]); +const WEIGHT_OK = new Set([ + '400', '500', + 'normal', 'bolder', 'lighter', + 'inherit', 'initial', 'unset', 'revert', +]); +const Z_OK = new Set(['0', '1', '-1', 'auto']); + +/** @type {{ rule: string, file: string, line: number, detail: string }[]} */ +const findings = []; + +function walk(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (/\.(vue|css)$/.test(entry.name)) out.push(full); + } + return out; +} + +function rel(abs) { + return path.relative(SRC, abs).replaceAll(path.sep, '/'); +} + +function lineOf(text, index) { + let n = 1; + for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++; + return n; +} + +function add(rule, file, line, detail) { + findings.push({ rule, file, line, detail }); +} + +function stripVarSpans(line) { + // Remove var(...) substrings so var() fallbacks don't trip hex checks. + return line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ''); +} + +function extractStyleBlocks(content) { + // For .vue: return [{text, baseLine}] for each
+ +
+
+ +
+

{{ t('app.authPageTitle') }}

+

{{ t('app.authPageMessage') }}

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + diff --git a/apps/kimi-web/src/api/config.ts b/apps/kimi-web/src/api/config.ts new file mode 100644 index 000000000..620e9eca8 --- /dev/null +++ b/apps/kimi-web/src/api/config.ts @@ -0,0 +1,102 @@ +// apps/kimi-web/src/api/config.ts +// Reads Vite env, builds REST/WS URLs, manages stable clientId. + +import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage'; + +const CLIENT_ID_KEY = STORAGE_KEYS.clientId; +const WEB_CLIENT_NAME = 'kimi-code-web'; +const WEB_CLIENT_UI_MODE = 'web'; + +export interface KimiApiConfig { + serverHttpUrl: string; + clientId: string; + clientName: string; + clientVersion: string; + clientUiMode: string; +} + +export function readKimiApiConfig(): KimiApiConfig { + return { + serverHttpUrl: normalizeServerOrigin(import.meta.env.VITE_KIMI_SERVER_HTTP_URL), + clientId: getClientId(), + clientName: WEB_CLIENT_NAME, + clientVersion: webClientVersion(), + clientUiMode: WEB_CLIENT_UI_MODE, + }; +} + +// Default to SAME-ORIGIN so we never depend on CORS: +// - dev: the SPA is served by Vite; the Vite dev proxy forwards /v1, /healthz +// and /v1/ws to the server (see vite.config.ts), so the browser only ever +// talks to its own origin. +// - prod: `kimi web` serves this built SPA from the server itself, so the +// server's origin already is the API origin. +// Set VITE_KIMI_SERVER_HTTP_URL to connect directly to an absolute server +// origin instead (that path does require the server to send CORS headers). +function defaultServerOrigin(): string { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://127.0.0.1:58627'; +} + +export function normalizeServerOrigin(value: string | undefined): string { + const raw = value && value.trim() ? value : defaultServerOrigin(); + const url = new URL(raw); + url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Strip the scheme for a compact display origin: `http://127.0.0.1:58627` → `127.0.0.1:58627`. */ +function shortOrigin(origin: string): string { + return origin.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +/** + * Address of the REAL server the client is connected to, shown in the status bar. + * Always the actual server — never the dev-proxy URL — since that's the thing + * worth knowing at a glance. Cases: + * - VITE_KIMI_SERVER_HTTP_URL set → that absolute server origin (direct mode). + * - dev (same-origin proxy) → the proxy's upstream target (the real server). + * - prod (server serves the SPA) → the page origin (it IS the server). + */ +export function serverEndpointLabel(): string { + const direct = import.meta.env.VITE_KIMI_SERVER_HTTP_URL; + if (direct && direct.trim()) return shortOrigin(normalizeServerOrigin(direct)); + + const proxy = + typeof __KIMI_DEV_PROXY_TARGET__ !== 'undefined' ? __KIMI_DEV_PROXY_TARGET__ : ''; + if (import.meta.env.DEV && proxy) return shortOrigin(proxy); + + const origin = + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''; + return shortOrigin(origin); +} + +// The real server serves everything (incl. healthz + ws) under the /api/v1 prefix. +export function buildRestUrl(origin: string, path: string): string { + return `${origin}/api/v1${path.startsWith('/') ? path : `/${path}`}`; +} + +export function buildWsUrl(origin: string, clientId: string): string { + const url = new URL(`${origin}/api/v1/ws`); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('client_id', clientId); + return url.toString(); +} + +function getClientId(): string { + const stored = safeGetString(CLIENT_ID_KEY); + if (stored) return stored; + const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`; + safeSetString(CLIENT_ID_KEY, generated); + return generated; +} + +function webClientVersion(): string { + return typeof __KIMI_WEB_VERSION__ === 'string' && __KIMI_WEB_VERSION__.trim() + ? __KIMI_WEB_VERSION__ + : '0.0.0-dev'; +} diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts new file mode 100644 index 000000000..a63d781cf --- /dev/null +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -0,0 +1,1443 @@ +// apps/kimi-web/src/api/daemon/agentEventProjector.ts +// +// Client-side projector: raw agent-core WS events → AppEvent[] +// +// The real daemon pushes raw agent-core events (NOT the projected "event.*" +// protocol events). This projector translates them into the same AppEvent union +// that the existing reducer (eventReducer.ts) consumes. +// +// Ported from the daemon-side reference implementation: +// apps/kimi-daemon/src/session/event-projector.ts +// apps/kimi-daemon/src/session/message-log.ts +// apps/kimi-daemon/src/session/usage-tracker.ts +// +// Usage: +// const projector = createAgentProjector(); +// const appEvents = projector.project(rawType, payload, sessionId); +// // call reset() when re-subscribing / resyncing a session + +import type { + AppEvent, + AppGoal, + AppInFlightTurn, + AppMessage, + AppMessageContent, + AppSessionUsage, + AppTask, +} from '../types'; +import { i18n } from '../../i18n'; +import { toolLabel, toolSummary } from '../../lib/toolMeta'; +import { toAppMessageContent } from './mappers'; +import type { WireMessageContent } from './wire'; + +// Subagent turns share the parent session id: their turn / step / delta / tool +// frames stream over the SAME session channel, each tagged with the subagent's +// own agentId (the main agent's is 'main'). They must NOT be folded into the +// parent transcript — doing so created empty "skeleton" assistant bubbles (a +// subagent turn.step.started opens a parent assistant message that never gets +// the main agent's text) and fragmented snippets (subagent deltas appended to +// the parent). The subagent's live progress is surfaced separately via the +// subagent.* → task → right-side detail panel path (the spawning `Agent` tool +// itself renders as a normal tool card in the transcript). This mirrors the +// server's InFlightTurnTracker, which likewise tracks only main-agent activity. +const MAIN_AGENT_ID = 'main'; +const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.use', + 'tool.call.started', + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.completed', +]); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function ulid(prefix = 'msg_'): string { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +/** Normalise the raw token usage shape emitted by agent-core. */ +function normalizeUsage(raw: unknown): { + input: number; + output: number; + cacheRead: number; + cacheCreate: number; +} { + if (!raw || typeof raw !== 'object') { + return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; + } + const u = raw as Record; + return { + input: u['inputOther'] ?? u['input_tokens'] ?? 0, + output: u['output'] ?? u['output_tokens'] ?? 0, + cacheRead: u['inputCacheRead'] ?? u['cache_read_input_tokens'] ?? 0, + cacheCreate: u['inputCacheCreation'] ?? u['cache_creation_input_tokens'] ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Per-session projector state +// --------------------------------------------------------------------------- + +interface SessionState { + // Turn ID → promptId binding + turnPromptId: Map; + currentPromptId: string | undefined; + + // Assistant message tracking + currentAssistantMsgId: string | undefined; + + // Per-turn accumulated stream lengths — aligned against the wire `offset` + // on volatile delta frames (v2 sync protocol) to skip duplicates and + // detect gaps after a snapshot seed. + turnTextLen: number; + turnThinkLen: number; + + // Tool timing + toolStartTimes: Map; + + // Usage accumulator + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheCreate: number; + contextTokens: number; + contextLimit: number; + turnCount: number; + model: string; + + // In-memory message log (mirrors daemon message-log.ts) + messages: AppMessage[]; + + // Subagent lifecycle deltas after spawned only carry subagentId. Keep the + // spawned metadata here so later updates can replace the full AppTask. + subagentMeta: Map; +} + +function createSessionState(): SessionState { + return { + turnPromptId: new Map(), + currentPromptId: undefined, + currentAssistantMsgId: undefined, + turnTextLen: 0, + turnThinkLen: 0, + toolStartTimes: new Map(), + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + model: '', + messages: [], + subagentMeta: new Map(), + }; +} + +function stringField(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(source: Record, key: string): number | undefined { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function nullableNumberField(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function mapGoalSnapshot(snapshot: unknown): AppGoal | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const s = snapshot as Record; + const budgetRaw = s['budget']; + const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record : {}; + const status = stringField(s, 'status'); + if (status !== 'active' && status !== 'paused' && status !== 'blocked' && status !== 'complete') return null; + const goalId = stringField(s, 'goalId') ?? stringField(s, 'goal_id') ?? 'goal'; + const objective = stringField(s, 'objective') ?? ''; + return { + goalId, + objective, + completionCriterion: stringField(s, 'completionCriterion') ?? stringField(s, 'completion_criterion'), + status, + turnsUsed: numberField(s, 'turnsUsed') ?? numberField(s, 'turns_used') ?? 0, + tokensUsed: numberField(s, 'tokensUsed') ?? numberField(s, 'tokens_used') ?? 0, + wallClockMs: numberField(s, 'wallClockMs') ?? numberField(s, 'wall_clock_ms') ?? 0, + terminalReason: stringField(s, 'terminalReason') ?? stringField(s, 'terminal_reason'), + budget: { + tokenBudget: nullableNumberField(budget, 'tokenBudget') ?? nullableNumberField(budget, 'token_budget'), + remainingTokens: nullableNumberField(budget, 'remainingTokens') ?? nullableNumberField(budget, 'remaining_tokens'), + turnBudget: nullableNumberField(budget, 'turnBudget') ?? nullableNumberField(budget, 'turn_budget'), + remainingTurns: nullableNumberField(budget, 'remainingTurns') ?? nullableNumberField(budget, 'remaining_turns'), + wallClockBudgetMs: nullableNumberField(budget, 'wallClockBudgetMs') ?? nullableNumberField(budget, 'wall_clock_budget_ms'), + remainingWallClockMs: nullableNumberField(budget, 'remainingWallClockMs') ?? nullableNumberField(budget, 'remaining_wall_clock_ms'), + overBudget: budget['overBudget'] === true || budget['over_budget'] === true, + }, + }; +} + +function patchSubagent( + state: SessionState, + sessionId: string, + subagentId: unknown, + patch: Partial, +): AppTask | null { + if (typeof subagentId !== 'string' || subagentId.length === 0) return null; + const prev = state.subagentMeta.get(subagentId) ?? { + id: subagentId, + sessionId, + kind: 'subagent', + description: 'Sub Agent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + } satisfies AppTask; + const next: AppTask = { ...prev, ...patch, id: subagentId, sessionId, kind: 'subagent' }; + state.subagentMeta.set(subagentId, next); + return next; +} + +export function subagentProgressText(rawType: string, payload: Record): string | null { + // "Started a step" fires on every step and adds no information — the phase + // badge already shows the subagent is working, so skip it to cut the noise. + if (rawType === 'turn.step.started') return null; + if (rawType === 'tool.use' || rawType === 'tool.call.started') { + const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool'; + const label = toolLabel(cleanToolName(name)); + const summary = toolArgSummary(name, payload['args'] ?? payload['input']); + return summary ? `Calling ${label}: ${summary}` : `Calling ${label}`; + } + if (rawType === 'tool.progress') { + const update = payload['update']; + if (update && typeof update === 'object') { + const text = stringField(update as Record, 'text'); + if (text) return capProgressText(text); + const message = stringField(update as Record, 'message'); + if (message) return capProgressText(message); + } + const message = stringField(payload, 'message'); + if (message) return capProgressText(message); + } + // tool.result lines ("Finished X") add noise without much information — the + // next call or the final summary already implies completion — so skip them. + if (rawType === 'tool.result') return null; + return null; +} + +/** Strip a trailing `_N` index that some subagents append to tool names in + * `tool.result` events (e.g. `Read_0` → `Read`) so the label resolves. */ +function cleanToolName(name: string): string { + return name.replace(/_\d+$/, ''); +} + +/** Cap a progress text chunk so a single huge tool output (e.g. a big command + * result) cannot dominate the panel. */ +const MAX_PROGRESS_TEXT = 2000; +function capProgressText(text: string): string { + return text.length > MAX_PROGRESS_TEXT ? `${text.slice(0, MAX_PROGRESS_TEXT)}…` : text; +} + +/** A concise, human-readable summary of a tool call's arguments for progress + * lines (e.g. a file path or shell command), instead of the full JSON blob. */ +function toolArgSummary(name: string, args: unknown): string { + if (args === undefined || args === null) return ''; + const arg = typeof args === 'string' ? args : JSON.stringify(args); + return toolSummary(name, arg); +} + +function projectSubagentProgress( + state: SessionState, + sessionId: string, + subagentId: string, + rawType: string, + payload: Record, + sideChannelAgents: ReadonlySet, +): AppEvent[] { + // Side-channel agents (e.g. BTW side chat) stream their own transcript via + // agentDelta events; don't pollute the main task output with generic step + // placeholders like "Started a step". + if (sideChannelAgents.has(subagentId) && rawType === 'turn.step.started') return []; + + // The subagent's own streamed text: forward each delta as a `text`-kind + // progress chunk so the reducer concatenates it into `AppTask.text`, letting + // the right-side detail panel show the subagent's output growing live (like + // a thinking block) instead of staying blank until the first tool call. + if (rawType === 'assistant.delta') { + const delta = stringField(payload, 'delta'); + if (!delta) return []; + // Ensure the subagent task exists before forwarding the text delta. A client + // that subscribed from a snapshot after `subagent.spawned` already fired + // never received the lifecycle taskCreated, and the reducer only applies + // taskProgress to existing tasks — without this, the deltas are dropped and + // the live detail stays blank until a non-text frame recreates the task. + const previous = state.subagentMeta.get(subagentId); + const task = patchSubagent(state, sessionId, subagentId, { + status: 'running', + subagentPhase: 'working', + startedAt: previous?.startedAt ?? new Date().toISOString(), + }); + const out: AppEvent[] = []; + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ + type: 'taskProgress', + sessionId, + taskId: subagentId, + outputChunk: delta, + stream: 'stdout', + kind: 'text', + }); + return out; + } + + const text = subagentProgressText(rawType, payload); + if (text === null || text.length === 0) return []; + const previous = state.subagentMeta.get(subagentId); + const task = patchSubagent(state, sessionId, subagentId, { + status: 'running', + subagentPhase: 'working', + startedAt: previous?.startedAt ?? new Date().toISOString(), + }); + const out: AppEvent[] = []; + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ type: 'taskProgress', sessionId, taskId: subagentId, outputChunk: text, stream: 'stdout' }); + return out; +} + +// --------------------------------------------------------------------------- +// Message-log helpers (inlined; mirrors message-log.ts) +// --------------------------------------------------------------------------- + +/** + * Decouple an emitted message from the projector's internal log. The reducer + * stores emitted messages by reference; the projector keeps mutating its own + * copy in place (`slot.text += delta`), so sharing the content objects makes + * the reducer's delta-append run on already-appended text — the first streamed + * chunk of every text/thinking block rendered twice. + */ +function cloneMessage(msg: AppMessage): AppMessage { + return { ...msg, content: msg.content.map((c) => ({ ...c })) }; +} + +function startAssistantMessage(state: SessionState, sessionId: string, promptId: string): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'assistant', + content: [], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function startUserMessage( + state: SessionState, + sessionId: string, + promptId: string, + userMessageId: string, + content: AppMessageContent[], + createdAt: string, +): AppMessage { + const msg: AppMessage = { + id: userMessageId, + sessionId, + role: 'user', + content, + createdAt, + promptId, + }; + state.messages.push(msg); + return msg; +} + +function toAppPromptContent(raw: unknown): AppMessageContent[] { + if (!Array.isArray(raw)) return []; + return raw.map((part) => toAppMessageContent(part as WireMessageContent)); +} + +/** + * Append a streamed text/thinking delta in stream order: continue the LAST + * content part when it has the same type, otherwise open a NEW part at the + * end. Returns the content index written (-1 if the message is unknown) so + * the emitted assistantDelta targets the same slot in the reducer. + * + * No per-type fixed slots: a step that goes think → text → think again gets + * three parts in call order instead of all thinking collapsing into one slot. + */ +function appendAssistantDelta( + state: SessionState, + messageId: string, + kind: 'text' | 'thinking', + delta: string, +): number { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return -1; + const last = msg.content.at(-1); + if (last && last.type === kind) { + if (kind === 'text') (last as { type: 'text'; text: string }).text += delta; + else (last as { type: 'thinking'; thinking: string }).thinking += delta; + return msg.content.length - 1; + } + msg.content.push(kind === 'text' ? { type: 'text', text: delta } : { type: 'thinking', thinking: delta }); + return msg.content.length - 1; +} + +function appendToolUse( + state: SessionState, + messageId: string, + toolCallId: string, + toolName: string, + input: unknown, + outputLines?: string[], +): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + msg.content.push({ type: 'toolUse', toolCallId, toolName, input, outputLines }); +} + +function toolProgressOutput(payload: Record): { outputChunk: string; stream: 'stdout' | 'stderr' } | null { + const update = payload['update']; + const updateRecord = update && typeof update === 'object' ? update as Record : null; + const streamRaw = updateRecord?.['stream'] ?? updateRecord?.['kind'] ?? payload['stream']; + const stream = streamRaw === 'stderr' ? 'stderr' : 'stdout'; + const chunk = + (typeof updateRecord?.['text'] === 'string' && updateRecord['text']) || + (typeof updateRecord?.['message'] === 'string' && updateRecord['message']) || + (typeof payload['chunk'] === 'string' && payload['chunk']) || + (typeof payload['output'] === 'string' && payload['output']) || + (typeof payload['message'] === 'string' && payload['message']) || + ''; + return chunk.length > 0 ? { outputChunk: chunk, stream } : null; +} + +function finishAssistantMessage(state: SessionState, messageId: string): void { + const msg = state.messages.find((m) => m.id === messageId); + // We record nothing extra here — status is implicit in the downstream reducer + void msg; +} + +function appendToolResultMessage( + state: SessionState, + sessionId: string, + toolCallId: string, + output: unknown, + isError: boolean, + promptId: string, +): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'tool', + content: [{ type: 'toolResult', toolCallId, output, isError }], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function getMsgById(state: SessionState, messageId: string): AppMessage | undefined { + return state.messages.find((m) => m.id === messageId); +} + +// --------------------------------------------------------------------------- +// Usage snapshot builder +// --------------------------------------------------------------------------- + +function buildUsageSnapshot(state: SessionState): AppSessionUsage { + return { + inputTokens: state.totalInput, + outputTokens: state.totalOutput, + cacheReadTokens: state.totalCacheRead, + cacheCreationTokens: state.totalCacheCreate, + totalCostUsd: 0, + contextTokens: state.contextTokens, + contextLimit: state.contextLimit, + turnCount: state.turnCount, + }; +} + +// --------------------------------------------------------------------------- +// AgentProjector +// --------------------------------------------------------------------------- + +export interface ProjectMeta { + /** + * Wire-level pre-append stream offset on volatile text-delta frames (v2 + * sync protocol). Used to skip duplicate deltas and detect gaps after a + * snapshot seed. + */ + offset?: number; +} + +export interface AgentProjector { + /** Project a single raw agent-core event into zero or more AppEvents. Never throws. */ + project(rawType: string, payload: unknown, sessionId: string, meta?: ProjectMeta): AppEvent[]; + /** + * Bind an externally-known promptId to the next turn.startd for this session. + * Call this right after submitPrompt() returns, before the first turn.started arrives. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** + * Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync): + * resets per-session state, builds the partially-streamed assistant message + * (thinking + text + running tool_use parts), and returns the AppEvents + * (sessionStatusChanged + messageCreated) to apply to the reducer. Live + * deltas continue appending; their wire `offset` aligns against the seeded + * text so the overlap window around snapshot/subscribe is exact. + */ + seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; + /** Reset all per-session state (call on re-subscribe / resync). */ + reset(sessionId: string): void; + /** + * Mark an agent id as a side-channel (e.g. BTW side chat) rather than a + * background subagent. Its text/thinking deltas and turn boundary are then + * emitted as agent-scoped events instead of being dropped. + */ + markSideChannelAgent(agentId: string): void; +} + +export function createAgentProjector(): AgentProjector { + const sessions = new Map(); + const sideChannelAgents = new Set(); + + function getOrCreate(sessionId: string): SessionState { + let s = sessions.get(sessionId); + if (!s) { + s = createSessionState(); + sessions.set(sessionId, s); + } + return s; + } + + function reset(sessionId: string): void { + sessions.set(sessionId, createSessionState()); + } + + function markSideChannelAgent(agentId: string): void { + sideChannelAgents.add(agentId); + } + + function bindNextPromptId(sessionId: string, promptId: string): void { + const s = getOrCreate(sessionId); + s.currentPromptId = promptId; + } + + function seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[] { + reset(sessionId); + const s = getOrCreate(sessionId); + + const promptId = turn.promptId ?? ulid('pr_'); + s.currentPromptId = promptId; + s.turnPromptId.set(turn.turnId, promptId); + + const msg = startAssistantMessage(s, sessionId, promptId); + if (turn.thinkingText.length > 0) { + msg.content.push({ type: 'thinking', thinking: turn.thinkingText }); + } + if (turn.assistantText.length > 0) { + msg.content.push({ type: 'text', text: turn.assistantText }); + } + for (const tool of turn.runningTools) { + const outputLines = + typeof tool.lastProgress?.text === 'string' && tool.lastProgress.text.length > 0 + ? [tool.lastProgress.text] + : undefined; + msg.content.push({ + type: 'toolUse', + toolCallId: tool.toolCallId, + toolName: tool.name, + input: tool.args ?? {}, + outputLines, + }); + s.toolStartTimes.set(tool.toolCallId, Date.now()); + } + s.currentAssistantMsgId = msg.id; + s.turnTextLen = turn.assistantText.length; + s.turnThinkLen = turn.thinkingText.length; + + return [ + { + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: promptId, + }, + { type: 'messageCreated', message: cloneMessage(msg) }, + ]; + } + + function project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + try { + return _project(rawType, payload, sessionId, meta); + } catch (error) { + // Defensive: log but never crash the caller + console.error('[agentProjector] Error projecting event:', rawType, error instanceof Error ? error.message : error); + return []; + } + } + + /** + * Align a live text-delta against the per-turn accumulated length using the + * wire `offset`. Returns 'skip' for duplicates (offset behind local state), + * 'gap' when deltas were missed (offset ahead — trigger a re-snapshot), and + * 'append' otherwise. + */ + function alignDelta(localLen: number, offset: number | undefined): 'append' | 'skip' | 'gap' { + if (offset === undefined) return 'append'; + if (offset < localLen) return 'skip'; + if (offset > localLen) return 'gap'; + return 'append'; + } + + function _project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + const s = getOrCreate(sessionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any; + const out: AppEvent[] = []; + + // Drop subagent-scoped transcript frames (see MAIN_AGENT_TRANSCRIPT_FRAMES). + // A subagent carries its own agentId; only the main agent's stream builds the + // visible transcript. Lifecycle frames (subagent.*, goal.*, background.*) are + // intentionally NOT in the set — they describe the subagent for the task view + // and must always be projected. + const frameAgentId: unknown = p?.agentId; + if (typeof frameAgentId === 'string' && frameAgentId !== MAIN_AGENT_ID) { + const isSideChannel = sideChannelAgents.has(frameAgentId); + // Side-channel agents (e.g. BTW side chat) stream text/thinking deltas and + // a turn boundary over the parent session channel. Route them to the web + // layer as agent-scoped events instead of dropping them or folding them + // into the parent transcript. + if (isSideChannel && (rawType === 'thinking.delta' || rawType === 'assistant.delta')) { + const deltaText: string = p?.delta ?? ''; + if (!deltaText) return []; + return [ + { + type: 'agentDelta' as const, + sessionId, + agentId: frameAgentId, + delta: { [rawType === 'thinking.delta' ? ('thinking' as const) : ('text' as const)]: deltaText }, + }, + ]; + } + if (isSideChannel && rawType === 'turn.ended') { + return [ + { type: 'agentTurnEnded' as const, sessionId, agentId: frameAgentId, reason: p?.reason }, + ]; + } + if (MAIN_AGENT_TRANSCRIPT_FRAMES.has(rawType)) { + return projectSubagentProgress(s, sessionId, frameAgentId, rawType, p ?? {}, sideChannelAgents); + } + } + + switch (rawType) { + // ----------------------------------------------------------------------- + case 'session.meta.updated': { + // The daemon auto-generates a title from the first prompt (and other + // clients can rename a session); it also reports the latest user prompt + // via patch.lastPrompt. It announces all of these via this event. We + // don't have the full AppSession here, so emit a lightweight + // sessionMetaUpdated that patches only the changed meta fields. + const title: string | undefined = p?.patch?.title ?? p?.title; + const lastPrompt: string | undefined = p?.patch?.lastPrompt; + const patch: { title?: string; lastPrompt?: string } = {}; + if (typeof title === 'string' && title.length > 0) patch.title = title; + if (typeof lastPrompt === 'string') patch.lastPrompt = lastPrompt; + if (patch.title !== undefined || patch.lastPrompt !== undefined) { + out.push({ type: 'sessionMetaUpdated', sessionId, ...patch }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.submitted': { + const promptId: string | undefined = p?.promptId; + const userMessageId: string | undefined = p?.userMessageId; + if (!promptId || !userMessageId) break; + const content = toAppPromptContent(p?.content); + if (content.length === 0) break; + s.currentPromptId = promptId; + const msg = startUserMessage( + s, + sessionId, + promptId, + userMessageId, + content, + typeof p?.createdAt === 'string' ? p.createdAt : new Date().toISOString(), + ); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.started': { + // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + const turnId: number = p?.turnId; + const existingPromptId = s.currentPromptId ?? ulid('pr_'); + s.currentPromptId = existingPromptId; + if (turnId !== undefined) { + s.turnPromptId.set(turnId, existingPromptId); + } + // Fresh turn → fresh per-turn stream offsets. + s.turnTextLen = 0; + s.turnThinkLen = 0; + + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: existingPromptId, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.started': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Joined mid-turn (reconnect/resync wiped the binding): synthesize a + // promptId like turn.started does, so the REST of the turn still + // renders instead of every following event being dropped. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + // Create a new pending assistant message + const msg = startAssistantMessage(s, sessionId, promptId); + s.currentAssistantMsgId = msg.id; + + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'thinking.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + // Same missed-turn-boundary self-heal as assistant.delta (see there). + if (meta?.offset === 0 && s.turnThinkLen > 0) { + s.turnThinkLen = 0; + } + + const align = alignDelta(s.turnThinkLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const thinkIdx = appendAssistantDelta(s, msgId, 'thinking', delta); + if (thinkIdx < 0) break; + s.turnThinkLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: thinkIdx, + delta: { thinking: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'assistant.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + // Self-heal a missed turn boundary: a pre-append offset of 0 while we + // still believe we are mid-stream means the daemon began a fresh + // assistant stream (new turn / retry) whose turn.started we never saw — + // e.g. the durable replay and the live volatile deltas raced on the + // cursor after a reconnect. Without this reset every delta has + // offset < turnTextLen and is SILENTLY skipped forever (skip, unlike + // gap, never recovers), so streaming dies until a full page reload. + if (meta?.offset === 0 && s.turnTextLen > 0) { + s.turnTextLen = 0; + } + + const align = alignDelta(s.turnTextLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + // Deltas were missed in the snapshot↔subscribe window — the only + // exact recovery is a fresh snapshot. historyCompacted is routed to + // onResync by the client wrapper, which reloads via snapshot. + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const textIdx = appendAssistantDelta(s, msgId, 'text', delta); + if (textIdx < 0) break; + s.turnTextLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: textIdx, + delta: { text: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'tool.use': + case 'tool.call.started': { + const msgId = s.currentAssistantMsgId; + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!msgId || !promptId) break; + + const toolCallId: string = p?.toolCallId; + // Real daemon field name is 'name' per event-projector.ts + const toolName: string = p?.name ?? p?.toolName ?? ''; + const args = p?.args ?? p?.input ?? {}; + + appendToolUse(s, msgId, toolCallId, toolName, args); + + const msg = getMsgById(s, msgId); + const contentIndex = msg ? msg.content.length - 1 : 0; + + // Record start time + s.toolStartTimes.set(toolCallId, Date.now()); + + // Emit messageUpdated so the reducer knows about the new tool-use slot + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'pending', + }); + } + void contentIndex; + break; + } + + // ----------------------------------------------------------------------- + case 'tool.call.delta': { + // Input streaming — no-op for the web client (content already in tool.call.started.args) + break; + } + + // ----------------------------------------------------------------------- + case 'tool.progress': { + const toolCallId: string = p?.toolCallId; + const progress = toolProgressOutput(p ?? {}); + if (toolCallId && progress) { + out.push({ + type: 'toolOutput', + sessionId, + toolCallId, + outputChunk: progress.outputChunk, + stream: progress.stream, + }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'tool.result': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Same mid-turn-join fallback as turn.step.started. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + const toolCallId: string = p?.toolCallId; + const output = p?.output; + const isError: boolean = p?.isError ?? false; + + const startTime = s.toolStartTimes.get(toolCallId) ?? Date.now(); + s.toolStartTimes.delete(toolCallId); + void (Date.now() - startTime); // duration — unused at client level + + const resultMsg = appendToolResultMessage(s, sessionId, toolCallId, output, isError, promptId); + out.push({ type: 'messageCreated', message: cloneMessage(resultMsg) }); + + // Reset assistant message tracking — next step.started will create a fresh one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.completed': { + const msgId = s.currentAssistantMsgId; + + // Feed usage + const u = normalizeUsage(p?.usage); + s.totalInput += u.input; + s.totalOutput += u.output; + s.totalCacheRead += u.cacheRead; + s.totalCacheCreate += u.cacheCreate; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'completed', + }); + } + } + break; + } + + // ----------------------------------------------------------------------- + case 'agent.status.updated': { + if (p?.model) s.model = p.model; + if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens; + if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens; + + out.push({ + type: 'sessionUsageUpdated', + sessionId, + usage: buildUsageSnapshot(s), + // Carry the live model so the status bar shows the real running model + // instead of falling back to the daemon's (empty) REST model. + model: s.model || undefined, + swarmMode: p?.swarmMode === true ? true : p?.swarmMode === false ? false : undefined, + // The agent reports plan mode here too (e.g. it auto-entered plan mode + // for a "make a plan" prompt). Carry it so the composer's plan toggle + // reflects the agent's real state, not just the user's manual choice. + planMode: p?.planMode === true ? true : p?.planMode === false ? false : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.ended': { + const msgId = s.currentAssistantMsgId; + const reason: string = p?.reason ?? 'completed'; + const durationMs = numberField(p ?? {}, 'durationMs'); + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: reason === 'failed' || reason === 'filtered' ? 'error' : 'completed', + durationMs, + }); + } + } + + s.turnCount++; + const usageSnapshot = buildUsageSnapshot(s); + out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); + + const newStatus = + reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle'; + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: newStatus, + previousStatus: 'running', + }); + + // Clear per-turn state. Reset the stream offsets too so a stale length + // from this turn can't wedge the next turn's delta alignment into a + // silent skip if its turn.started is missed across a reconnect. + s.currentAssistantMsgId = undefined; + s.currentPromptId = undefined; + s.turnTextLen = 0; + s.turnThinkLen = 0; + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.completed': { + // No-op at AppEvent level — turn.ended already handles the transition to idle + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.retrying': + case 'turn.step.interrupted': { + // Discard current assistant message; next step.started will create a new one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'subagent.spawned': { + const taskId = typeof p?.subagentId === 'string' && p.subagentId.length > 0 ? p.subagentId : ulid('task_'); + const task: AppTask = { + id: taskId, + sessionId, + kind: 'subagent', + description: typeof p?.description === 'string' ? p.description : p?.subagentName ?? 'Sub Agent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + subagentType: typeof p?.subagentName === 'string' ? p.subagentName : undefined, + parentToolCallId: typeof p?.parentToolCallId === 'string' ? p.parentToolCallId : undefined, + swarmIndex: typeof p?.swarmIndex === 'number' ? p.swarmIndex : undefined, + runInBackground: p?.runInBackground === true, + }; + s.subagentMeta.set(task.id, task); + out.push({ + type: 'taskCreated', + sessionId, + task, + }); + break; + } + + case 'subagent.started': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'working', + status: 'running', + startedAt: new Date().toISOString(), + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + + case 'subagent.suspended': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'suspended', + status: 'running', + suspendedReason: typeof p?.reason === 'string' ? p.reason : undefined, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + + case 'subagent.completed': { + const outputPreview = typeof p?.resultSummary === 'string' ? p.resultSummary : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'completed', + status: 'completed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'completed', + outputPreview, + }); + break; + } + + case 'subagent.failed': { + const outputPreview = typeof p?.error === 'string' ? p.error : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'failed', + status: 'failed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'failed', + outputPreview, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'error': { + // Fold into an unknown event so the reducer pushes a warning string + out.push({ + type: 'unknown', + raw: { _agentError: true, code: p?.code, message: p?.message }, + }); + break; + } + + case 'warning': { + out.push({ + type: 'unknown', + raw: { _agentWarning: true, message: p?.message }, + }); + break; + } + + // ----------------------------------------------------------------------- + // Background tasks (e.g. a backgrounded Bash command). Real daemon shape: + // payload.info = { taskId, description, status, startedAt(ms), endedAt, + // kind:'process', command, pid, exitCode }. + case 'background.task.started': { + const info = (p?.info ?? {}) as Record; + const startedAt = + typeof info.startedAt === 'number' ? new Date(info.startedAt).toISOString() : undefined; + const taskId = + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : ulid('task_'); + const description = + typeof info.description === 'string' + ? info.description + : typeof info.command === 'string' + ? info.command + : i18n.global.t('tasks.defaultDescription'); + const command = typeof info.command === 'string' ? info.command : undefined; + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: taskId, + sessionId, + kind: 'bash', + description, + command, + status: 'running', + createdAt: startedAt ?? new Date().toISOString(), + startedAt, + outputPreview: command !== undefined ? `$ ${command}` : undefined, + }, + }); + break; + } + case 'background.task.terminated': { + const info = (p?.info ?? {}) as Record; + const failed = + info.status === 'failed' || + (typeof info.exitCode === 'number' && info.exitCode !== 0); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : '', + status: failed ? 'failed' : 'completed', + // Do NOT set outputPreview here. The command is already kept on the + // task as `command`; setting outputPreview to `$ ` would + // clobber any real output captured by polling and prevents the UI + // from fetching the final terminal output after the task finishes. + }); + break; + } + + // ----------------------------------------------------------------------- + case 'compaction.completed': { + // Compaction replaced a batch of old messages with a summary on the + // daemon side. The visible transcript is NOT reloaded (the client keeps + // the scrollback and the reducer appends a divider marker); the + // historyCompacted signal still fires so seq bookkeeping and any + // non-compaction consumers stay correct. + const result = (p?.result ?? {}) as Record; + out.push({ + type: 'compactionCompleted', + sessionId, + tokensBefore: typeof result.tokensBefore === 'number' ? result.tokensBefore : undefined, + tokensAfter: typeof result.tokensAfter === 'number' ? result.tokensAfter : undefined, + summary: typeof result.summary === 'string' ? result.summary : undefined, + }); + out.push({ + type: 'historyCompacted', + sessionId, + beforeSeq: 0, + reason: 'auto_compact', + }); + break; + } + + case 'compaction.started': { + out.push({ + type: 'compactionStarted', + sessionId, + trigger: p?.trigger === 'manual' ? 'manual' : 'auto', + instruction: typeof p?.instruction === 'string' ? p.instruction : undefined, + }); + break; + } + + case 'compaction.cancelled': { + out.push({ type: 'compactionCancelled', sessionId }); + break; + } + + case 'goal.updated': { + const goal = mapGoalSnapshot(p?.snapshot ?? null); + out.push({ + type: 'goalUpdated', + sessionId, + goal: goal?.status === 'complete' ? null : goal, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'cron.fired': { + // A scheduled reminder fired into the session. agent-core persists the + // injected user message (so a refresh renders it via messagesToTurns), + // but turn.steer() does NOT broadcast a prompt.submitted / message.created + // for it — synthesize one here so the notice shows up live too. A later + // snapshot reload replaces the message log wholesale, so this synthesized + // copy never duplicates the persisted one. The promptId is intentionally + // omitted: the web client caches every user message's promptId into + // promptIdBySession for Stop/abort, and a synthetic id the daemon would + // reject would clobber the real active promptId. The reducer already skips + // optimistic-echo reconciliation for cron-origin messages, so no promptId + // is needed for de-dup either. + const origin = p?.origin; + const promptText = stringField(p ?? {}, 'prompt'); + if ( + origin && + typeof origin === 'object' && + (origin as Record)['kind'] === 'cron_job' && + promptText + ) { + const msg: AppMessage = { + id: ulid('cron_'), + sessionId, + role: 'user', + content: [{ type: 'text', text: promptText }], + createdAt: new Date().toISOString(), + metadata: { origin: origin as Record }, + }; + s.messages.push(msg); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + } + break; + } + + // ----------------------------------------------------------------------- + // Explicitly known but not projected + case 'compaction.blocked': + case 'hook.result': + case 'mcp.server.status': + case 'skill.activated': + case 'tool.list.updated': + break; + + // ----------------------------------------------------------------------- + default: + // Unknown future events — safe no-op + break; + } + + return out; + } + + return { project, bindNextPromptId, seedInFlight, reset, markSideChannelAgent }; +} + +// --------------------------------------------------------------------------- +// Helpers for integration layer +// --------------------------------------------------------------------------- + +/** + * Detect whether an incoming WS frame type is a raw agent-core event + * (as opposed to a projected "event.*" protocol event or a control frame). + * + * Raw agent-core events do NOT start with "event." and are not control frames. + * Control frames: server_hello, ack, ping, resync_required, error. + */ +const CONTROL_FRAME_TYPES = new Set([ + 'server_hello', + 'ack', + 'ping', + 'resync_required', + 'error', + 'pong', +]); + +export function isRawAgentCoreEvent(frameType: string): boolean { + if (frameType.startsWith('event.')) return false; + if (CONTROL_FRAME_TYPES.has(frameType)) return false; + return true; +} + +/** + * Agent-core event names the projector knows how to project. These are the + * raw events the real daemon emits. The same names may arrive WITH an "event." + * prefix (newer daemon) or WITHOUT it (older daemon). + */ +const KNOWN_AGENT_CORE_TYPES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.call.started', + 'tool.use', // alias the daemon may use for tool.call.started + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.submitted', + 'prompt.completed', + 'session.meta.updated', + 'compaction.started', + 'compaction.completed', + 'compaction.cancelled', + 'goal.updated', + 'error', + 'warning', + 'subagent.spawned', + 'subagent.started', + 'subagent.suspended', + 'subagent.completed', + 'subagent.failed', + 'background.task.started', + 'background.task.terminated', + 'cron.fired', +]); + +/** + * "event."-prefixed names that are GENUINE protocol events (control/projected + * events produced server-side). The agent projector must NOT re-handle these — + * they go through the existing toAppEvent() path. This includes approval / + * question requests (which drive the approval/question UI) and the no-op-but- + * known streaming/tool protocol events. + */ +const PROTOCOL_EVENT_NAMES = new Set([ + // Session lifecycle (projected) + 'session.created', + 'session.updated', + 'session.deleted', + 'session.status_changed', + 'session.usage_updated', + 'session.history_compacted', + // Message lifecycle (projected) + 'message.created', + 'message.updated', + // Approval / Question — MUST stay on the protocol path to drive the UI + 'approval.requested', + 'approval.resolved', + 'approval.expired', + 'question.requested', + 'question.answered', + 'question.dismissed', + // Background tasks (projected) + 'task.created', + 'task.progress', + 'task.completed', + // No-op-but-known protocol streaming / tool events + 'assistant.tool_use_started', + 'assistant.tool_use_delta', + 'assistant.tool_use_completed', + 'assistant.completed', + 'tool.started', + 'tool.output', + 'tool.completed', +]); + +/** + * Names that are ambiguous between the raw agent-core form (payload.delta is a + * STRING) and the already-projected protocol form (payload.delta is an object + * { text? | thinking? }, or the payload carries message_id / content_index). + */ +const AMBIGUOUS_DELTA_NAMES = new Set(['assistant.delta', 'thinking.delta']); + +export type FrameRoute = + | { route: 'protocol' } + | { route: 'agent'; agentType: string } + | { route: 'ignore' }; + +/** + * Classify a (possibly "event."-prefixed) WS frame into the path it should take. + * + * - 'protocol' → hand the original frame to toAppEvent() (existing path). + * - 'agent' → hand `agentType` + payload to the agent projector. + * - 'ignore' → drop (no session context / unroutable). + * + * Robust to all three observed shapes: + * 1) raw agent-core (no prefix): turn.started, assistant.delta{delta:'…'} + * 2) "event."-prefixed agent-core: event.turn.started, event.assistant.delta{delta:'…'} + * 3) genuine protocol "event.*" events: event.message.created, event.session.*, … + */ +export function classifyFrame(rawType: string, payload: unknown): FrameRoute { + if (CONTROL_FRAME_TYPES.has(rawType)) return { route: 'ignore' }; + + const hasPrefix = rawType.startsWith('event.'); + const name = hasPrefix ? rawType.slice('event.'.length) : rawType; + + // Ambiguous delta events: disambiguate by payload shape regardless of prefix. + if (AMBIGUOUS_DELTA_NAMES.has(name)) { + if (deltaIsRawAgentCore(payload)) return { route: 'agent', agentType: name }; + // Object delta or protocol-shaped payload → projected protocol event. + return { route: 'protocol' }; + } + + // Unprefixed frames are raw agent-core (real daemon) when we know the name. + if (!hasPrefix) { + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown unprefixed name with no protocol meaning → still try the projector + // (it safely no-ops on unknown types and advances nothing). + return { route: 'agent', agentType: name }; + } + + // Prefixed frames: genuine protocol events take priority. + if (PROTOCOL_EVENT_NAMES.has(name)) return { route: 'protocol' }; + // Prefixed agent-core event (e.g. event.turn.started) → strip + project. + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown "event.*" → let toAppEvent() record it as an unknown protocol event. + return { route: 'protocol' }; +} + +/** + * True when an assistant.delta / thinking.delta payload is in the RAW agent-core + * form: payload.delta is a plain string, and there is no protocol-only field + * (message_id / content_index). The protocol form uses delta:{text|thinking}. + */ +function deltaIsRawAgentCore(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const p = payload as Record; + if ('message_id' in p || 'content_index' in p) return false; + return typeof p['delta'] === 'string'; +} diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts new file mode 100644 index 000000000..7249b804a --- /dev/null +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -0,0 +1,1424 @@ +// apps/kimi-web/src/api/daemon/client.ts +// DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs. + +import type { KimiApiConfig } from '../config'; +import { buildRestUrl, buildWsUrl } from '../config'; +import type { + AppConfig, + AppMessage, + AppMessageRole, + AppModel, + AppProvider, + ProviderRefreshResult, + AppSession, + AppSkill, + AppSessionCursor, + AppSessionRuntimeStatus, + AppSessionSnapshot, + AppSessionStatus, + AppTask, + AppTaskStatus, + AppTerminal, + AppWorkspace, + ApprovalResponse, + FsBrowseResult, + FsEntry, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, + Page, + PageRequest, + PromptSubmission, + PromptSubmitResult, + QuestionResponse, +} from '../types'; +import { createAgentProjector } from './agentEventProjector'; +import { DaemonHttpClient } from './http'; +import { + toAppApprovalRequest, + toAppConfig, + toAppEvent, + toAppFsEntry, + toAppMessage, + toAppModel, + toAppProvider, + toAppQuestionRequest, + toAppSession, + toAppTask, + toWireApprovalResponse, + toWirePromptSubmission, + toWireQuestionResponse, + toWireSessionStatus, + toAppWorkspace, + wireEventSeq, + wireEventSessionId, +} from './mappers'; +import type { + WireAuthResult, + WireBackgroundTask, + WireConfig, + WireEvent, + WireFileMeta, + WireFsBrowseResult, + WireFsEntry, + WireFsHomeResult, + WireMessage, + WireModel, + WireOAuthCancelResult, + WireOAuthLoginPollResult, + WireOAuthLoginStartResult, + WirePage, + WirePromptSubmitResult, + WirePromptSteerResult, + WireProvider, + WireProviderRefreshResult, + WireSession, + WireSessionAbortResult, + WireSessionWarning, + WireSessionWarningsResponse, + WireSessionRuntimeStatus, + WireSessionSnapshot, + WireWorkspace, + WireLogoutResult, +} from './wire'; +import { DaemonEventSocket } from './ws'; + +// --------------------------------------------------------------------------- +// Wire response shapes for endpoints not in shared wire.ts +// --------------------------------------------------------------------------- + +interface WireHealth { + status: 'ok'; + uptime_sec: number; +} + +interface WireMeta { + server_version: string; + server_id: string; + started_at: string; + capabilities: Record; + open_in_apps?: string[]; + dangerous_bypass_auth?: boolean; +} + +interface WireAbortResult { + aborted: boolean; + at_seq?: number; +} + +interface WireDismissResult { + dismissed: boolean; + dismissed_at: string; +} + +interface WireApprovalResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireQuestionResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireCancelResult { + cancelled: true; +} + +interface WireSkillDescriptor { + name: string; + description: string; + path: string; + source: string; + type?: string; + disable_model_invocation?: boolean; +} + +interface WireArchiveResult { + archived: true; +} + +interface WireListDirectoryResult { + items: WireFsEntry[]; + children_by_path?: Record; + truncated: boolean; +} + +interface WireReadFileResult { + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + language_id?: string; + line_count?: number; + is_binary: boolean; +} + +interface WireSearchFilesResult { + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + match_positions: number[]; + }>; + truncated: boolean; +} + +interface WireGrepFilesResult { + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + files_scanned: number; + truncated: boolean; + elapsed_ms: number; +} + +interface WireGitStatusResult { + branch: string; + ahead: number; + behind: number; + entries: Record; + additions: number; + deletions: number; + pullRequest?: { number: number; state: string; url: string } | null; +} + +interface WireDiffResult { + path: string; + diff: string; +} + +interface WireTerminal { + id: string; + session_id: string; + cwd: string; + shell: string; + cols: number; + rows: number; + status: 'running' | 'exited'; + created_at: string; + exited_at?: string; + exit_code?: number | null; +} + +function toAppTerminal(data: WireTerminal): AppTerminal { + return { + id: data.id, + sessionId: data.session_id, + cwd: data.cwd, + shell: data.shell, + cols: data.cols, + rows: data.rows, + status: data.status, + createdAt: data.created_at, + exitedAt: data.exited_at, + exitCode: data.exit_code, + }; +} + +/** + * historyCompacted reasons caused by compaction itself. These do NOT trigger a + * snapshot reload: the client keeps the visible scrollback and renders a + * divider marker instead. Every other reason (delta_gap, history_rewrite, …) + * still means "cached messages are stale" and goes through onResync. + */ +function isCompactionReason(reason: string): boolean { + return reason === 'auto_compact' || reason === 'manual_compact'; +} + +// --------------------------------------------------------------------------- +// DaemonKimiWebApi +// --------------------------------------------------------------------------- + +export class DaemonKimiWebApi implements KimiWebApi { + private readonly http: DaemonHttpClient; + private readonly config: KimiApiConfig; + + constructor(config: KimiApiConfig) { + this.config = config; + this.http = new DaemonHttpClient(config.serverHttpUrl, { + clientId: config.clientId, + clientName: config.clientName, + clientVersion: config.clientVersion, + clientUiMode: config.clientUiMode, + }); + } + + // ------------------------------------------------------------------------- + // Health / Meta + // ------------------------------------------------------------------------- + + async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> { + // Real daemon returns { ok: true }; the older shape was { status, uptime_sec }. + const data = await this.http.get>('/healthz'); + return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 }; + } + + async getMeta(): Promise<{ + serverVersion: string; + serverId: string; + startedAt: string; + capabilities: Record; + openInApps: string[]; + dangerousBypassAuth: boolean; + }> { + const data = await this.http.get('/meta'); + return { + serverVersion: data.server_version, + serverId: data.server_id, + startedAt: data.started_at, + capabilities: data.capabilities, + openInApps: Array.isArray(data.open_in_apps) ? data.open_in_apps : [], + dangerousBypassAuth: data.dangerous_bypass_auth === true, + }; + } + + // ------------------------------------------------------------------------- + // Sessions + // ------------------------------------------------------------------------- + + async listSessions( + input?: PageRequest & { + status?: AppSessionStatus; + workspaceId?: string; + includeArchive?: boolean; + archivedOnly?: boolean; + excludeEmpty?: boolean; + }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + status: input?.status ? toWireSessionStatus(input.status) : undefined, + include_archive: input?.includeArchive, + archived_only: input?.archivedOnly, + exclude_empty: input?.excludeEmpty, + // PRESUMED — daemon supports ?workspace_id= once the registry ships; it + // ignores unknown query params until then, so this is safe to always send. + workspace_id: input?.workspaceId, + }; + const data = await this.http.get>('/sessions', query); + return { + items: data.items.map(toAppSession), + hasMore: data.has_more, + }; + } + + async createSession(input: { + title?: string; + cwd?: string; + model?: string; + workspaceId?: string; + }): Promise { + // The real daemon requires `metadata` to be an object (rejects a missing + // metadata with 40001), so always send it — with cwd when provided. + const body: Record = { + metadata: input.cwd !== undefined ? { cwd: input.cwd } : {}, + }; + // PRESUMED — daemon resolves cwd from workspace_id once the registry ships. + // We ALSO send metadata.cwd (above) as the fallback so today's daemon, which + // only understands cwd, still creates the session in the right folder. + if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId; + if (input.title !== undefined) body['title'] = input.title; + if (input.model !== undefined) body['agent_config'] = { model: input.model }; + const data = await this.http.post('/sessions', body); + return toAppSession(data); + } + + // GET /sessions/{id} — fetch one session (deep links to sessions outside the + // first listSessions page). + async getSession(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}`, + ); + return toAppSession(data); + } + + // The daemon has no PATCH on sessions; mutating title/metadata/agent_config + // (model + runtime controls) goes through POST /sessions/{id}/profile with a + // SessionUpdate body { title?, metadata?, agent_config? }. Runtime controls in + // agent_config are dispatched to the matching core RPCs (setModel/setThinking/ + // setPermission/enterPlan|cancelPlan); the live values are read back from + // GET /sessions/{id}/status (the profile echo's agent_config can be stale/""). + async updateSession( + sessionId: string, + input: { + title?: string; + cwd?: string; + model?: string; + permissionMode?: string; + planMode?: boolean; + swarmMode?: boolean; + goalObjective?: string; + goalControl?: 'pause' | 'resume' | 'cancel'; + thinking?: string; + }, + ): Promise { + const body: Record = {}; + if (input.title !== undefined) body['title'] = input.title; + if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd }; + const agentConfig: Record = {}; + if (input.model !== undefined) agentConfig['model'] = input.model; + if (input.permissionMode !== undefined) agentConfig['permission_mode'] = input.permissionMode; + if (input.planMode !== undefined) agentConfig['plan_mode'] = input.planMode; + if (input.swarmMode !== undefined) agentConfig['swarm_mode'] = input.swarmMode; + if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; + if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; + if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/profile`, + body, + ); + return toAppSession(data); + } + + /** + * GET /sessions/{id}/status — the session's live runtime state (current model, + * thinking level, permission mode, plan flag, and context-window usage). This + * is the source of truth for the status line; Session.agent_config.model can + * be "" on the read path. + */ + async getSessionStatus(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/status`, + ); + return { + model: data.model && data.model.length > 0 ? data.model : null, + thinkingEffort: data.thinking_level, + permission: data.permission, + planMode: data.plan_mode === true, + swarmMode: data.swarm_mode === true, + contextTokens: data.context_tokens ?? 0, + maxContextTokens: data.max_context_tokens ?? 0, + contextUsage: data.context_usage ?? 0, + }; + } + + async getSessionWarnings(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/warnings`, + ); + return data.warnings ?? []; + } + + async archiveSession(sessionId: string): Promise<{ archived: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:archive`, + {}, + ); + return data; + } + + // POST /sessions/{id}:restore — clear the archived flag. The daemon returns + // the full restored session, so callers can merge it straight back into lists. + async restoreSession(sessionId: string): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:restore`, + {}, + ); + return toAppSession(data); + } + + // ------------------------------------------------------------------------- + // Messages + // ------------------------------------------------------------------------- + + async listMessages( + sessionId: string, + input?: PageRequest & { role?: AppMessageRole }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + role: input?.role, + }; + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/messages`, + query, + ); + return { + items: data.items.map(toAppMessage), + hasMore: data.has_more, + }; + } + + /** + * v2 initial sync: atomic session state at an `as_of_seq` watermark. + * Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor). + */ + async getSessionSnapshot(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/snapshot`, + ); + return { + asOfSeq: data.as_of_seq, + epoch: data.epoch, + session: toAppSession(data.session), + // Snapshot messages are already chronological ascending. + messages: data.messages.items.map(toAppMessage), + hasMoreMessages: data.messages.has_more, + inFlightTurn: + data.in_flight_turn === null + ? null + : { + turnId: data.in_flight_turn.turn_id, + assistantText: data.in_flight_turn.assistant_text, + thinkingText: data.in_flight_turn.thinking_text, + runningTools: data.in_flight_turn.running_tools.map((t) => ({ + toolCallId: t.tool_call_id, + name: t.name, + args: t.args, + description: t.description, + lastProgress: t.last_progress, + })), + promptId: data.in_flight_turn.current_prompt_id, + }, + pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), + pendingQuestions: data.pending_questions.map(toAppQuestionRequest), + }; + } + + // ------------------------------------------------------------------------- + // Prompt + // ------------------------------------------------------------------------- + + async submitPrompt( + sessionId: string, + input: PromptSubmission, + ): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts`, + toWirePromptSubmission(input), + ); + return { + promptId: data.prompt_id, + userMessageId: data.user_message_id, + status: data.status, + }; + } + + // POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the + // active turn (TUI ctrl+s). Throws PROMPT_NOT_FOUND when there is no active + // turn anymore (the queued prompt then starts its own turn — callers may + // treat that as success). + async steerPrompts( + sessionId: string, + promptIds: string[], + ): Promise<{ steered: boolean; promptIds: string[] }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts:steer`, + { prompt_ids: promptIds }, + ); + return { steered: data.steered, promptIds: data.prompt_ids }; + } + + async abortPrompt( + sessionId: string, + promptId: string, + ): Promise<{ aborted: boolean; atSeq?: number }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`, + undefined, + { allowCodes: [40903] }, + ); + // data.aborted is false when 40903 (prompt already completed) — that's correct + return { aborted: data.aborted, atSeq: data.at_seq }; + } + + // POST /sessions/{id}:abort — cancel whatever is running in the session, + // including skill activations that bypass IPromptService. + async abortSession(sessionId: string): Promise<{ aborted: boolean }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:abort`, + {}, + ); + return { aborted: data.aborted }; + } + + // POST /sessions/{id}:compact — request history compaction. Returns {}; + // progress and completion arrive via the WS compaction.* events (the + // transcript itself is not reloaded — a divider marker is appended). + async compactSession(sessionId: string, instruction?: string): Promise { + await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:compact`, + instruction ? { instruction } : {}, + ); + } + + // POST /sessions/{id}:undo — remove the last `count` turns from history. The + // response carries the resulting messages + status, but we re-sync the session + // afterwards for the authoritative (un-paginated) transcript, so we only need + // the call to succeed here. + async undoSession(sessionId: string, count = 1): Promise { + await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:undo`, + { count }, + ); + } + + // POST /sessions/{id}:fork — fork the session into a new child session. + async forkSession(sessionId: string, input?: { title?: string }): Promise { + const body: Record = {}; + if (input?.title !== undefined) body['title'] = input.title; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:fork`, + body, + ); + return toAppSession(data); + } + + // POST /sessions/{id}/children — create a child ("side chat") session. The + // daemon forks the parent (so the child inherits its context) and tags it with + // parent_session_id + child_session_kind. + async createChildSession(sessionId: string, input?: { title?: string }): Promise { + const body: Record = {}; + if (input?.title !== undefined) body['title'] = input.title; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/children`, + body, + ); + return toAppSession(data); + } + + // GET /sessions/{id}/children — list a session's child sessions. + async listChildSessions(sessionId: string): Promise { + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/children`, + ); + return data.items.map(toAppSession); + } + + // POST /sessions/{id}:btw — start a TUI-style side-channel agent. Follow-up + // prompts use the returned agent_id on the normal /prompts route. + async startBtw(sessionId: string): Promise<{ agentId: string }> { + const data = await this.http.post<{ agent_id: string }>( + `/sessions/${encodeURIComponent(sessionId)}:btw`, + {}, + ); + return { agentId: data.agent_id }; + } + + // ------------------------------------------------------------------------- + // Approval / Question + // ------------------------------------------------------------------------- + + async respondApproval( + sessionId: string, + approvalId: string, + response: ApprovalResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, + toWireApprovalResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async respondQuestion( + sessionId: string, + questionId: string, + response: QuestionResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`, + toWireQuestionResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async dismissQuestion( + sessionId: string, + questionId: string, + ): Promise<{ dismissed: true; dismissedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`, + undefined, + { allowCodes: [40909] }, + ); + // 40909 means question.dismissed — that's the success path per spec + return { dismissed: true, dismissedAt: data.dismissed_at }; + } + + // ------------------------------------------------------------------------- + // Tasks + // ------------------------------------------------------------------------- + + async listTasks(sessionId: string, status?: AppTaskStatus): Promise { + const query: Record = { + status: status, + }; + const data = await this.http.get<{ items: WireBackgroundTask[] }>( + `/sessions/${encodeURIComponent(sessionId)}/tasks`, + query, + ); + return data.items.map(toAppTask); + } + + async getTask( + sessionId: string, + taskId: string, + input?: { withOutput?: boolean; outputBytes?: number }, + ): Promise { + const query: Record = { + with_output: input?.withOutput, + output_bytes: input?.outputBytes, + }; + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`, + query, + ); + return toAppTask(data); + } + + async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`, + ); + return data; + } + + async listTerminals(sessionId: string): Promise { + const data = await this.http.get<{ items: WireTerminal[] }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + ); + return data.items.map(toAppTerminal); + } + + async createTerminal( + sessionId: string, + input: { cwd?: string; shell?: string; cols?: number; rows?: number } = {}, + ): Promise { + const body: Record = { + cwd: input.cwd, + shell: input.shell, + cols: input.cols, + rows: input.rows, + }; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + body, + ); + return toAppTerminal(data); + } + + async getTerminal(sessionId: string, terminalId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}`, + ); + return toAppTerminal(data); + } + + async closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }> { + return this.http.post<{ closed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}:close`, + ); + } + + // ------------------------------------------------------------------------- + // Skills — slash-invocable skills (session- or workspace-scoped) + // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } + // GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session) + // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } + // ------------------------------------------------------------------------- + + async listSkills(sessionId: string): Promise { + const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( + `/sessions/${encodeURIComponent(sessionId)}/skills`, + ); + return (data.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + source: s.source, + })); + } + + async listSkillsForWorkspace(workspaceId: string): Promise { + const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/skills`, + ); + return (data.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + source: s.source, + })); + } + + async activateSkill( + sessionId: string, + skillName: string, + args?: string, + ): Promise<{ activated: true; skillName: string }> { + const data = await this.http.post<{ activated: true; skill_name: string }>( + `/sessions/${encodeURIComponent(sessionId)}/skills/${encodeURIComponent(skillName)}:activate`, + args !== undefined && args.length > 0 ? { args } : {}, + ); + return { activated: data.activated, skillName: data.skill_name }; + } + + // ------------------------------------------------------------------------- + // File System + // ------------------------------------------------------------------------- + + async listDirectory( + sessionId: string, + input: { path?: string; depth?: number; includeGitStatus?: boolean }, + ): Promise<{ + items: FsEntry[]; + childrenByPath?: Record; + truncated: boolean; + }> { + const body: Record = {}; + if (input.path !== undefined) body['path'] = input.path; + if (input.depth !== undefined) body['depth'] = input.depth; + if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:list`, + body, + ); + const childrenByPath = data.children_by_path + ? Object.fromEntries( + Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]), + ) + : undefined; + return { + items: data.items.map(toAppFsEntry), + childrenByPath, + truncated: data.truncated, + }; + } + + async readFile( + sessionId: string, + input: { path: string; offset?: number; length?: number }, + ): Promise<{ + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + languageId?: string; + lineCount?: number; + isBinary: boolean; + }> { + const body: Record = { path: input.path }; + if (input.offset !== undefined) body['offset'] = input.offset; + if (input.length !== undefined) body['length'] = input.length; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:read`, + body, + ); + return { + path: data.path, + content: data.content, + encoding: data.encoding, + size: data.size, + truncated: data.truncated, + etag: data.etag, + mime: data.mime, + languageId: data.language_id, + lineCount: data.line_count, + isBinary: data.is_binary, + }; + } + + async searchFiles( + sessionId: string, + input: { query: string; limit?: number }, + ): Promise<{ + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + matchPositions: number[]; + }>; + truncated: boolean; + }> { + const body: Record = { query: input.query }; + if (input.limit !== undefined) body['limit'] = input.limit; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + body, + ); + return { + items: data.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + score: item.score, + matchPositions: item.match_positions, + })), + truncated: data.truncated, + }; + } + + async grepFiles( + sessionId: string, + input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, + ): Promise<{ + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + filesScanned: number; + truncated: boolean; + elapsedMs: number; + }> { + const body: Record = { pattern: input.pattern }; + if (input.regex !== undefined) body['regex'] = input.regex; + if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:grep`, + body, + ); + return { + files: data.files, + filesScanned: data.files_scanned, + truncated: data.truncated, + elapsedMs: data.elapsed_ms, + }; + } + + async getGitStatus( + sessionId: string, + paths?: string[], + ): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }> { + const body: Record = {}; + if (paths !== undefined) body['paths'] = paths; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:git_status`, + body, + ); + return { + branch: data.branch, + ahead: data.ahead, + behind: data.behind, + entries: data.entries, + additions: data.additions, + deletions: data.deletions, + pullRequest: data.pullRequest ?? null, + }; + } + + async getFileDiff( + sessionId: string, + path: string, + ): Promise<{ path: string; diff: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:diff`, + { path }, + ); + return { path: data.path, diff: data.diff }; + } + + getFileDownloadUrl(sessionId: string, path: string): string { + const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/'); + return buildRestUrl( + this.config.serverHttpUrl, + `/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`, + ); + } + + async openFile( + sessionId: string, + input: { path: string; line?: number }, + ): Promise<{ opened: true }> { + const body: Record = { path: input.path }; + if (input.line !== undefined) body['line'] = input.line; + return this.http.post<{ opened: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:open`, + body, + ); + } + + async revealFile( + sessionId: string, + input: { path: string }, + ): Promise<{ revealed: true }> { + return this.http.post<{ revealed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:reveal`, + { path: input.path }, + ); + } + + async openInApp( + sessionId: string, + appId: string, + path: string, + line?: number, + ): Promise { + const body: Record = { app_id: appId, path }; + if (line !== undefined) body['line'] = line; + await this.http.post<{ opened: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:open-in`, + body, + ); + } + + // ------------------------------------------------------------------------- + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + // ------------------------------------------------------------------------- + + /** + * List the registered workspaces. + * PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and + * the composable DERIVES workspaces from the current sessions' cwds. So the + * switcher + grouping work immediately off existing sessions until the daemon + * ships the registry. + */ + async listWorkspaces(): Promise { + try { + const data = await this.http.get>('/workspaces'); + return (data.items ?? []).map(toAppWorkspace); + } catch { + return []; + } + } + + /** + * Register a workspace by folder path. + * PRESUMED — POST /api/v1/workspaces { root, name? }. Throws on error (e.g. + * path not found) so the caller can surface it to the user. + */ + async addWorkspace(input: { root: string; name?: string }): Promise { + const body: Record = { root: input.root }; + if (input.name !== undefined) body['name'] = input.name; + const data = await this.http.post('/workspaces', body); + return toAppWorkspace(data); + } + + /** + * Remove a registered workspace. + * PRESUMED — DELETE /api/v1/workspaces/:id. On error this throws. + */ + async deleteWorkspace(id: string): Promise { + await this.http.delete(`/workspaces/${encodeURIComponent(id)}`); + } + + /** + * Rename a workspace (display name only). + * PATCH /api/v1/workspaces/:id { name }. On error this throws. + */ + async updateWorkspace(id: string, input: { name: string }): Promise { + const data = await this.http.patch( + `/workspaces/${encodeURIComponent(id)}`, + { name: input.name }, + ); + return toAppWorkspace(data); + } + + /** + * Browse directories under `path` (defaults to $HOME on the daemon). + * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so + * the picker can distinguish "browse failed" from "directory has no children". + */ + async browseFs(path?: string): Promise { + try { + const data = await this.http.get('/fs:browse', { path }); + return { + path: data.path, + parent: data.parent, + entries: (data.entries ?? []).map((e) => ({ + name: e.name, + path: e.path, + isDir: e.is_dir, + isGitRepo: e.is_git_repo, + branch: e.branch, + })), + }; + } catch { + return { path: '', parent: null, entries: [] }; + } + } + + /** + * Get the picker start directory + recently-used roots. + * PRESUMED — GET /api/v1/fs:home. On error returns empty defaults. + */ + async getFsHome(): Promise<{ home: string; recentRoots: string[] }> { + try { + const data = await this.http.get('/fs:home'); + return { home: data.home, recentRoots: data.recent_roots ?? [] }; + } catch { + return { home: '', recentRoots: [] }; + } + } + + // ------------------------------------------------------------------------- + // Models + Providers + // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. + // ------------------------------------------------------------------------- + + async listModels(): Promise { + // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } + const data = await this.http.get<{ items: WireModel[] }>('/models'); + return data.items.map(toAppModel); + } + + async listProviders(): Promise { + // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } + const data = await this.http.get<{ items: WireProvider[] }>('/providers'); + return data.items.map(toAppProvider); + } + + async addProvider(input: { + type: string; + apiKey?: string; + baseUrl?: string; + defaultModel?: string; + }): Promise { + // PRESUMED endpoint: POST /v1/providers → WireProvider + const body: Record = { type: input.type }; + if (input.apiKey !== undefined) body['api_key'] = input.apiKey; + if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; + if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; + const data = await this.http.post('/providers', body); + return toAppProvider(data); + } + + async deleteProvider(id: string): Promise<{ deleted: true }> { + // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } + return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); + } + + async refreshProvider(id: string): Promise { + const data = await this.http.post( + `/providers/${encodeURIComponent(id)}:refresh`, + ); + return toProviderRefreshResult(data); + } + + async refreshAllProviders(): Promise { + const data = await this.http.post('/providers:refresh'); + return toProviderRefreshResult(data); + } + + async refreshOAuthProviderModels(): Promise { + const data = await this.http.post('/providers:refresh_oauth'); + return toProviderRefreshResult(data); + } + + // ------------------------------------------------------------------------- + // Config — REAL endpoints + // ------------------------------------------------------------------------- + + async getConfig(): Promise { + const data = await this.http.get('/config'); + return toAppConfig(data); + } + + async setConfig(patch: Partial): Promise { + const wirePatch: Record = {}; + const keyMap: Record = { + providers: 'providers', + defaultProvider: 'default_provider', + defaultModel: 'default_model', + models: 'models', + thinking: 'thinking', + planMode: 'plan_mode', + yolo: 'yolo', + defaultPermissionMode: 'default_permission_mode', + defaultPlanMode: 'default_plan_mode', + permission: 'permission', + hooks: 'hooks', + services: 'services', + mergeAllAvailableSkills: 'merge_all_available_skills', + extraSkillDirs: 'extra_skill_dirs', + loopControl: 'loop_control', + background: 'background', + experimental: 'experimental', + telemetry: 'telemetry', + raw: 'raw', + }; + for (const [key, value] of Object.entries(patch)) { + const wireKey = keyMap[key as keyof AppConfig]; + if (wireKey !== undefined) { + wirePatch[wireKey] = value; + } + } + const data = await this.http.post('/config', wirePatch); + return toAppConfig(data); + } + + // ------------------------------------------------------------------------- + // Auth — REAL endpoints + // ------------------------------------------------------------------------- + + async getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }> { + const data = await this.http.get('/auth'); + return { + ready: data.ready, + providersCount: data.providers_count, + defaultModel: data.default_model, + managedProvider: data.managed_provider + ? { status: data.managed_provider.status } + : null, + }; + } + + async startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }> { + const data = await this.http.post('/oauth/login', {}); + return { + flowId: data.flow_id, + provider: data.provider, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + userCode: data.user_code, + expiresIn: data.expires_in, + interval: data.interval, + status: data.status, + expiresAt: data.expires_at, + }; + } + + async pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null> { + // data may be null if no flow is active + const data = await this.http.get('/oauth/login'); + if (!data) return null; + return { + flowId: data.flow_id, + status: data.status, + resolvedAt: data.resolved_at, + }; + } + + async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { + const data = await this.http.delete('/oauth/login'); + return { cancelled: data.cancelled, status: data.status }; + } + + async logout(): Promise<{ loggedOut: boolean }> { + const data = await this.http.post('/oauth/logout', {}); + return { loggedOut: data.logged_out }; + } + + // ------------------------------------------------------------------------- + // File upload + // ------------------------------------------------------------------------- + + async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> { + const formData = new FormData(); + formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload')); + if (input.name !== undefined) { + formData.append('name', input.name); + } + const data = await this.http.postForm('/files', formData); + return { + id: data.id, + name: data.name, + mediaType: data.media_type, + size: data.size, + }; + } + + getFileUrl(fileId: string): string { + return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`); + } + + /** Fetch a file's bytes with the Bearer credential attached. Use this (not + * getFileUrl) when the bytes feed a