diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 15471a032..b5fb0fd50 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -11,6 +11,8 @@ 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. @@ -145,7 +147,8 @@ Clarify session status typing for internal SDK callers. `@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. -- If a PR contains both web UI changes and server API changes, split them into separate changesets so each entry has a focused description. +- 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: @@ -155,25 +158,54 @@ Web-only fix: "@moonshot-ai/kimi-code": patch --- -Fix the web chat not scrolling to the bottom after sending a message. +web: Fix the chat not scrolling to the bottom after sending a message. ``` -Web UI plus server APIs in the same PR (split into two changesets): +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 --- -Add the server-hosted web UI, including chat layout and session list behaviors. +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": minor +"@moonshot-ai/kimi-code": patch --- -Add the server REST and WebSocket APIs that power the web UI. +Fix the transcript jumping to the top when scrolling up through history during streaming output. ``` ## Red Flags @@ -188,3 +220,6 @@ Add the server REST and WebSocket APIs that power the web UI. - 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 index c79016a0d..adde7c1b3 100644 --- a/.agents/skills/pre-changelog/SKILL.md +++ b/.agents/skills/pre-changelog/SKILL.md @@ -37,9 +37,10 @@ If the CLI changelog is not in the diff (for example an SDK-only release), stop 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. -- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value. -- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. +- **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). diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index d4af78708..25e542d4b 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -93,13 +93,13 @@ Use upstream order: newest version first. 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: @@ -107,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. -### 4. 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 | @@ -140,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` @@ -151,14 +176,15 @@ 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. @@ -238,6 +264,7 @@ Structural fidelity does not mean literal translation. The Chinese entries shoul 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: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` @@ -254,7 +281,8 @@ Guidelines: - **Keep usage hints to one short clause**. - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` - Better: `例如 kimi web --allowed-host example.com。` -- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, and config keys as-is. +- **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: @@ -292,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. @@ -397,6 +426,7 @@ Return the PR URL to the user when done. - 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. @@ -407,6 +437,10 @@ Return the PR URL to the user when done. |---|---| | 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 | @@ -425,6 +459,7 @@ Return the PR URL to the user when done. | 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 diff --git a/.changeset/README.md b/.changeset/README.md index dd568888b..42457c132 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -29,7 +29,6 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` -- `kimi-migration-legacy` Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@moonshot-ai/*` internal workspace packages. diff --git a/.changeset/config.json b/.changeset/config.json index c430a5cee..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": [], @@ -10,8 +10,7 @@ "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web", - "kimi-migration-legacy" + "@moonshot-ai/vis-web" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbc4897f..4611d7f5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,24 @@ 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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16b9c0f12..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 diff --git a/.gitignore b/.gitignore index 46dca949e..203492e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ docker-compose.yml 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 877553f49..003359f31 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,6 +150,7 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/AGENTS.md b/AGENTS.md index a9f77b457..f8cdf7c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,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 c559b54c0..901b7a6d2 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -6,3 +6,6 @@ agents/ # 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 2e1179e0e..859d87410 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,313 @@ # @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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 269260ebe..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.21.1", + "version": "0.23.4", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -28,6 +28,7 @@ "files": [ "dist", "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -48,7 +49,7 @@ "provenance": true }, "scripts": { - "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-web-assets.mjs", + "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", @@ -75,17 +76,16 @@ }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9", - "koffi": "^2.16.0", "node-pty": "^1.1.0" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.74.0", "@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:*", @@ -94,6 +94,7 @@ "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", diff --git a/apps/kimi-code/scripts/copy-native-assets.mjs b/apps/kimi-code/scripts/copy-native-assets.mjs new file mode 100644 index 000000000..dad365a06 --- /dev/null +++ b/apps/kimi-code/scripts/copy-native-assets.mjs @@ -0,0 +1,38 @@ +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'packages/pi-tui/native'); +const target = resolve(appRoot, 'native'); + +// pi-tui ships platform-specific native helpers only for darwin/win32; +// Linux has no native helper, so there is nothing to copy for it. +const PLATFORMS = ['darwin', 'win32']; + +async function assertPrebuilds(platform) { + const dir = resolve(source, platform, 'prebuilds'); + try { + const info = await stat(dir); + if (!info.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + throw new Error( + `pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`, + ); + } + return dir; +} + +await rm(target, { recursive: true, force: true }); +await mkdir(target, { recursive: true }); + +for (const platform of PLATFORMS) { + const srcPrebuilds = await assertPrebuilds(platform); + const dstPrebuilds = resolve(target, platform, 'prebuilds'); + await cp(srcPrebuilds, dstPrebuilds, { recursive: true }); +} + +console.log(`Copied pi-tui native prebuilds to ${target}`); diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 1e9ca8c3f..3f50b969c 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -2,13 +2,16 @@ 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. @@ -48,14 +51,14 @@ const child = spawn( // esbuild transform sees `experimentalDecorators: true` for DI parameter // decorators in agent-core. Mirrors `dev:server` in package.json. '--tsconfig', - './tsconfig.dev.json', + resolve(APP_ROOT, 'tsconfig.dev.json'), '--import', - '../../build/register-raw-text-loader.mjs', - './src/main.ts', + 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/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 1521d6716..39fd70b45 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([ '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/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/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/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 6f9379259..3bb3a705f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -44,7 +44,11 @@ import { createKimiCodeHostIdentity } from './version'; * * 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 is unref'd so it never keeps the loop alive on its own. + * 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; @@ -61,7 +65,6 @@ async function raceWithTimeout(promise: Promise, timeoutMs: number): Promi timedOut = true; resolve(); }, timeoutMs); - timer.unref?.(); }); try { await Promise.race([guarded, timedOutSignal]); @@ -179,6 +182,7 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); @@ -230,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; @@ -338,6 +342,7 @@ async function resolvePromptSession( model, permission: 'auto', additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + drainAgentTasksOnStop: true, }); installHeadlessHandlers(session); return { @@ -427,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) @@ -464,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 ( @@ -510,7 +529,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))); @@ -543,6 +584,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(); + } }); } @@ -593,7 +648,9 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeToolResult(): void {} - flushAssistant(): void {} + flushAssistant(): void { + this.assistantWriter.finish(); + } discardAssistant(): void {} diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 1bd55a84b..caeae907c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -25,6 +25,7 @@ import { KimiTUI } from '#/tui/index'; 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'; @@ -61,6 +62,7 @@ export async function runShell( const harness = createKimiHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), + skillDirs: opts.skillsDirs, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { @@ -133,6 +135,59 @@ 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(); @@ -151,13 +206,10 @@ export async function runShell( 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(); @@ -171,6 +223,7 @@ export async function runShell( mcp_ms: mcpMs, }); } catch (error) { + removeCrashHandlers(); setCrashPhase('shutdown'); trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 84070a736..cc633934c 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -59,6 +59,10 @@ export interface EnsureDaemonOptions { 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). */ @@ -202,6 +206,8 @@ interface SpawnDaemonChildOptions { insecureNoTls?: boolean; allowRemoteShutdown?: boolean; allowRemoteTerminals?: boolean; + dangerousBypassAuth?: boolean; + keepAlive?: boolean; allowedHosts?: readonly string[]; idleGraceMs?: number; } @@ -235,6 +241,12 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess 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)); } @@ -320,6 +332,8 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise', '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.', @@ -134,6 +139,11 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) '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.`, @@ -183,13 +193,27 @@ export async function handleRunCommand( 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. + // 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; - const token = deps.resolveToken?.(); + // 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 @@ -202,8 +226,9 @@ export async function handleRunCommand( ? formatReadyBanner(origin, host, { token, networkAddresses: deps.networkAddresses, + dangerousBypassAuth: effectiveBypass, }) - : formatReadyLine(origin, token); + : formatReadyLine(origin, token, effectiveBypass); deps.stdout.write(output); if (opts.open === true) { deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); @@ -211,14 +236,14 @@ export async function handleRunCommand( }; if (opts.foreground === true) { const run = deps.startServerForeground ?? startServerForeground; - await run(parsed, { + await run(runOptions, { onReady: (origin) => { writeReady({ origin, reused: false, host: parsed.host }); }, }); return; } - const result = await deps.startServerBackground(parsed); + const result = await deps.startServerBackground(runOptions); writeReady(result); } @@ -230,8 +255,30 @@ function formatReuseNotice(origin: string): string { ); } -function formatReadyLine(origin: string, token: string | undefined): string { - return `Kimi server: ${buildOpenableUrl(origin, token)}\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.')}`, + ]; } /** @@ -251,6 +298,8 @@ export async function startServerBackground( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, allowedHosts: options.allowedHosts, idleGraceMs: options.idleGraceMs, }); @@ -296,14 +345,18 @@ async function runServerInProcess( let running: RunningServer | undefined; let stopping = false; - const idle = mode.daemon - ? createIdleShutdownHandler({ - graceMs: options.idleGraceMs, - onIdle: () => { - void shutdown('idle'); - }, - }) - : undefined; + // 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; @@ -330,6 +383,7 @@ async function runServerInProcess( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, allowedHosts: options.allowedHosts, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { @@ -356,7 +410,9 @@ async function runServerInProcess( }); const readyFields = mode.daemon - ? { address: running.address, idleGraceMs: options.idleGraceMs } + ? 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'); @@ -421,6 +477,8 @@ interface FormatReadyBannerOptions { 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( @@ -452,6 +510,12 @@ function formatReadyBanner( '', ]; + 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, diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index 09acad29e..aa2b686ce 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -50,8 +50,18 @@ export interface ParsedServerOptions { 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). */ @@ -69,8 +79,12 @@ export interface ServerCliOptions { 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. */ @@ -78,15 +92,24 @@ export interface ServerCliOptions { } 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: parseHost(opts.host), + host, port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, insecureNoTls: opts.insecureNoTls !== false, allowRemoteShutdown: opts.allowRemoteShutdown === true, allowRemoteTerminals: opts.allowRemoteTerminals === true, - allowedHosts: parseAllowedHostArgs(opts.allowedHost), + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts, + keepAlive, daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index 2e7f49b4b..b228c913e 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -32,6 +32,7 @@ export interface InitializeCliTelemetryOptions { readonly version: string; readonly uiMode: string; readonly model?: string; + readonly sessionId?: string; } export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { @@ -54,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, }); diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 1ad8aefa3..5fda96d6a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -488,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) { @@ -596,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(); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index a54bfa2bd..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -172,6 +172,16 @@ export function main(): void { } }) .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( diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index b1ebfecfd..d4c4fee7e 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -11,7 +11,7 @@ * 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'; 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/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/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 1b5c24fe1..9d9597419 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,10 +1,10 @@ -import type { - ExperimentalFeatureState, - FlagId, - ModelAlias, - PermissionMode, - Session, - ThinkingEffort, +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'; @@ -19,7 +19,7 @@ import { PermissionSelectorComponent } from '../components/dialogs/permission-se 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 { 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'; @@ -35,6 +35,16 @@ import type { SlashCommandHost } from './dispatch'; 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) { @@ -97,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; } @@ -120,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.'); } } @@ -141,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; } @@ -164,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.'); } } @@ -224,10 +234,11 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): host.showError('No model selected. Run /model to select one first.'); return; } - const segments = segmentsFor(model); + const effective = effectiveModelAlias(model); + const segments = segmentsFor(effective); const arg = args.trim().toLowerCase(); if (arg.length === 0) { - showEffortPicker(host, model, segments); + showEffortPicker(host, effective, segments); return; } if (!segments.includes(arg)) { @@ -327,10 +338,8 @@ 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( @@ -420,7 +429,11 @@ async function performModelSwitch( host.track('model_switch', { model: alias }); } if (effort !== prevEffort) { - host.track('thinking_toggle', { effort }); + host.track('thinking_toggle', { + enabled: effort !== 'off', + effort, + from: prevEffort, + }); } } @@ -508,10 +521,8 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi 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( @@ -584,7 +595,7 @@ export async function applyExperimentalFeatureChanges( return; } - const experimental: Partial> = {}; + const experimental: Record = {}; for (const change of changes) { experimental[change.id] = change.enabled; } @@ -651,9 +662,7 @@ 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) { diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7d56fdb95..7508bc06a 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,4 +1,4 @@ -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'; diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 881e1a40b..de790b906 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -372,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 { diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 278938c27..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -9,6 +9,7 @@ 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, @@ -58,29 +59,43 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, - }); + // 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') { - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); - return; - } + 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); + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.showStatus(feedbackIdLine(res.feedbackId)); - host.track(FEEDBACK_TELEMETRY_EVENT); - if (attachmentFailed) { - host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + 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; } } @@ -198,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise 0; @@ -380,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 be90abf00..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,8 +9,8 @@ * the edge and adding them would just churn the diff renderer. */ -import { Container } from '@earendil-works/pi-tui'; -import type { Component } 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'; 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 93ab6e7e7..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, visibleWidth } 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, @@ -59,6 +59,10 @@ export class MoonLoader extends Text { } } + dispose(): void { + this.stop(); + } + setLabel(label: string): void { this.label = label; this.updateDisplay(); 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 733d5b8f8..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,8 +9,8 @@ * 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'; diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 3db1de3cf..10cdecbdb 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -3,10 +3,12 @@ * 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 { AppState } from '#/tui/types'; import { currentTheme } from '#/tui/theme'; @@ -25,6 +27,7 @@ export class WelcomeComponent implements Component { 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!'); @@ -33,7 +36,7 @@ export class WelcomeComponent implements Component { : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); const model = isLoggedOut ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); return ['', title, prompt, `Model: ${model}`].map((line) => truncateToWidth(line, safeWidth, '…'), ); @@ -71,7 +74,7 @@ export class WelcomeComponent implements Component { const modelValue = isLoggedOut ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') - : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); const infoLines = [ labelStyle('Directory: ') + this.state.workDir, 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 f837dc046..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,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; 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 670e612cf..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,7 +14,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} 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'; 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 15974959f..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,10 +24,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} 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 { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; @@ -218,17 +218,19 @@ function buildBody(block: ApprovalPreviewBlock): BuiltBody { } function buildDiffBody(block: DiffDisplayBlock): BuiltBody { - // renderDiffLines emits a `+N -M path` header on its first line followed - // by every changed line. We pull the header out into the viewer chrome so - // the body is purely scrollable diff content; this also means we don't - // double-render the path. - const rendered = renderDiffLines( + // 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, - 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) }; 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 23e92c8f8..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,7 +15,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 90a924757..9ade9350c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -13,8 +13,8 @@ * 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 { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -24,6 +24,7 @@ const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { 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; @@ -32,6 +33,9 @@ export class CompactionComponent extends Container { private canceled = false; private tokensBefore: number | undefined; private tokensAfter: number | undefined; + private summary: string | undefined; + private summaryText: Text | undefined; + private expanded = false; constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); @@ -51,32 +55,48 @@ export class CompactionComponent extends Container { private addInstructionChild(): void { if (this.instruction !== undefined) { - this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0)); + 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 line with fresh theme colours. - if (this.instruction !== undefined) { - // Remove the last child if it is the instruction line (it is always - // added after headerText and Spacer). - if (this.children.length > 2) { - this.children.pop(); - } - this.addInstructionChild(); + // 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): void { + 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(); } @@ -88,6 +108,39 @@ 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(); } @@ -100,7 +153,11 @@ export class CompactionComponent extends Container { this.tokensBefore !== undefined && this.tokensAfter !== undefined ? 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 = currentTheme.fg('warning', STATUS_BULLET); 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 8a3150bf4..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,7 +17,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts index 498c4191e..2678899ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -4,7 +4,7 @@ import { matchesKey, truncateToWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; 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 44042f057..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,7 +5,7 @@ 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 { SELECT_POINTER } from '#/tui/constant/symbols'; 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 38ac3bd9e..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 @@ -19,7 +19,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = 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 c35d55399..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'; 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 1f931eb5a..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,7 +15,7 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { @@ -33,7 +33,7 @@ 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' }, 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 c2308c18a..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, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; @@ -37,7 +37,8 @@ export interface ModelSelection { } 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 { @@ -49,10 +50,13 @@ export function providerDisplayName(provider: string): string { export function createModelChoiceOptions( models: Record, ): 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 { @@ -78,9 +82,10 @@ export interface ModelSelectorOptions { function createModelChoices(models: Record): 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})` }; }); } @@ -374,12 +379,6 @@ export class ModelSelectorComponent extends Container implements Focusable { const segments = segmentsFor(choice.model); const active = this.effectiveEffort(choice); const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); - // Always-on models (including effort-capable ones) additionally surface an - // unsupported Off so it's explicit that thinking cannot be disabled — same - // shape as the legacy always-on control. - if (availability === 'always-on') { - rendered.push(unavailable('Off')); - } 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 91668b4f0..4b2db673d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -6,20 +6,17 @@ 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.', }, ]; 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 fcc09941d..d7066c77c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -6,7 +6,7 @@ import { 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'; @@ -28,6 +28,27 @@ 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'; @@ -304,7 +325,8 @@ export type PluginsPanelSelection = | { readonly kind: 'details'; readonly id: string } | { readonly kind: 'reload' } | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { readonly kind: 'install-source'; readonly source: string }; + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; export interface PluginsPanelOptions { readonly installed: readonly PluginSummary[]; @@ -402,7 +424,19 @@ export class PluginsPanelComponent extends Container implements Focusable { } private get officialEntries(): readonly PluginMarketplaceEntry[] { - return this.marketplaceEntries.filter((entry) => entry.tier === 'official'); + // 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[] { @@ -516,6 +550,10 @@ export class PluginsPanelComponent extends Container implements Focusable { 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 }); } } @@ -622,6 +660,7 @@ export class PluginsPanelComponent extends Container implements Focusable { lines: string[], width: number, entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, ): void { const colors = currentTheme.palette; if (this.market.status === 'loading' || this.market.status === 'idle') { @@ -637,7 +676,7 @@ export class PluginsPanelComponent extends Container implements Focusable { 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, width)); + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); } } const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; @@ -649,7 +688,11 @@ export class PluginsPanelComponent extends Container implements Focusable { } private renderOfficial(lines: string[], width: number): void { - this.renderMarketplaceTab(lines, width, this.officialEntries); + // 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 { @@ -662,7 +705,9 @@ export class PluginsPanelComponent extends Container implements Focusable { 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 = marketplaceEntryStatus(entry, this.installedVersions); + 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); 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 8805c24a9..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,7 +42,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; 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 ba14033f7..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,7 +15,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { 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 bb5ec512f..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,7 +9,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; 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 38c1da2bb..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,7 +5,7 @@ import { visibleWidth, type Component, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; 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 3680eecbf..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 @@ -20,7 +20,7 @@ import { matchesKey, truncateToWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { renderTabStrip } from '#/tui/utils/tab-strip'; 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 fdcd81aac..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,7 +17,7 @@ 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 { currentTheme } from '#/tui/theme'; 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 0a47a2f83..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,7 +21,7 @@ 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 { SELECT_POINTER } from '@/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts index 77d82ccdd..37320f92b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -5,7 +5,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; 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 05bd81b15..e3532b157 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -11,12 +11,12 @@ import { visibleWidth, type SelectItem, type TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; 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'; @@ -114,10 +114,8 @@ 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 { @@ -137,7 +135,6 @@ export class CustomEditor extends Editor { /** 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 @@ -166,19 +163,20 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; private argumentHints: ReadonlyMap = new Map(); + private autocompleteWasShowing = false; setArgumentHints(hints: ReadonlyMap): void { this.argumentHints = hints; } - constructor(tui: TUI) { + 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. const theme = createEditorTheme(); - super(tui, theme, { paddingX: 4 }); + 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 @@ -211,6 +209,16 @@ export class CustomEditor extends Editor { }; } + 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 { const { line, col } = this.getCursor(); const lines = this.getLines(); @@ -250,7 +258,38 @@ 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; @@ -430,13 +469,6 @@ export class CustomEditor extends Editor { return; } - const newlineInput = getNewlineInput(normalized); - if (newlineInput !== undefined) { - this.onInsertNewline?.(); - super.handleInput(newlineInput); - return; - } - if (matchesKey(normalized, Key.up)) { if (this.getText().length === 0 && this.onUpArrowEmpty) { if (this.onUpArrowEmpty()) return; @@ -600,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; @@ -751,8 +780,7 @@ export function wrapWithSideBorders( 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 cb9029bb7..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,4 +1,4 @@ -import { readdirSync, statSync } from 'node:fs'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { @@ -8,7 +8,7 @@ import { 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; @@ -27,13 +27,13 @@ interface FsMentionCandidate { /** * Kimi wrapper around pi-tui's combined autocomplete provider. * - * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available and only the current working directory is involved. While managed fd - * is downloading, when it is unavailable, or when the session has additional - * roots, a small filesystem fallback keeps `@` file and folder completion usable - * across every root. 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; @@ -56,7 +56,7 @@ export class FileMentionProvider implements AutocompleteProvider { expanded.push({ ...cmd, name: alias }); } } - this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath); + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -75,7 +75,11 @@ export class FileMentionProvider implements AutocompleteProvider { // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { - if (this.fdPath === null || this.additionalDirs.length > 0) { + // 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, @@ -227,6 +231,21 @@ export 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 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 index d9d16936d..b6969c630 100644 --- a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -6,7 +6,7 @@ import { type SelectItem, type SelectListLayoutOptions, type SelectListTheme, -} from '@earendil-works/pi-tui'; +} 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. 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 00ede4a26..1fec48b26 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; } interface Cluster { @@ -239,7 +241,13 @@ export function renderDiffLinesClustered( 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 cc8ef4d56..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,7 +12,7 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; 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 71c53332e..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,8 +15,8 @@ * - 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 type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; 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 173be9951..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 { 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 721711801..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,7 +5,7 @@ * to align after the bullet. */ -import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +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'; 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 3d968bad8..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,4 +1,4 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +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'; 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 5ca2acf02..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,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +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'; 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 0cff92d50..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,7 +7,7 @@ * the richer completion card (the `/goal` box), not this marker. */ -import { truncateToWidth, 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 { STATUS_BULLET } from '#/tui/constant/symbols'; 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 be9df81af..d01f580fa 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -19,7 +19,7 @@ import { visibleWidth, wrapTextWithAnsi, type Component, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; 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 d6b2c6207..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,7 +7,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } 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'; diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts index 230cf3e83..afbc2b444 100644 --- a/apps/kimi-code/src/tui/components/messages/plugin-command.ts +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -11,7 +11,7 @@ * context; the TUI only consumes the `plugin_command.activated` event. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; 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 3910be1ab..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,8 +20,8 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +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 { currentTheme } from '#/tui/theme'; 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 1a28c7c64..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,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; @@ -48,8 +48,15 @@ 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(currentTheme.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)); } } @@ -68,24 +75,23 @@ export class ShellExecutionComponent extends Container { 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, 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 index 3c5fc20da..ca99f2e76 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -1,4 +1,4 @@ -import { Container, Text } from '@earendil-works/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; 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 907e91e9d..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,7 +12,7 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; 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 c23c356ce..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,4 +1,4 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; 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 a246d83ff..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,7 +5,13 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; import { currentTheme } from '#/tui/theme'; @@ -16,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; @@ -47,7 +57,8 @@ type Colorize = (text: string) => string; function displayModelName(alias: string, models: Record): 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 { @@ -138,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 index 5a6885ac4..325ae6eb2 100644 --- a/apps/kimi-code/src/tui/components/messages/step-summary.ts +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; 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 0308fada7..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,4 +1,4 @@ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index ca0847581..23a038c70 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -5,7 +5,7 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import { Text, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, 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 e1913213d..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,11 +5,13 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, TUI } from '@earendil-works/pi-tui'; +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, @@ -33,16 +35,14 @@ 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; @@ -474,6 +474,10 @@ 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 { @@ -498,6 +502,9 @@ class PrefixedWrappedLine implements Component { this.tailLines !== undefined && wrapped.length > this.tailLines ? wrapped.slice(wrapped.length - this.tailLines) : wrapped; + 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}`, @@ -548,6 +555,9 @@ 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; /** @@ -577,6 +587,7 @@ export class ToolCallComponent extends Container { private subagentElapsedTimer: ReturnType | undefined; private subagentStartedAtMs: number | undefined; private subagentEndedAtMs: number | undefined; + private subagentSpinnerFrame = 0; // ── Live progress lines ────────────────────────────────────────── // @@ -1036,11 +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 { @@ -1260,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 { @@ -1429,6 +1444,20 @@ export class ToolCallComponent extends Container { 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, @@ -1697,25 +1726,24 @@ export class ToolCallComponent extends Container { private buildSingleSubagentHeader(): string { const phase = this.getDerivedSubagentPhase(); - const isFailed = phase === 'failed'; const isDone = phase === 'done'; - const bullet = isFailed - ? currentTheme.fg('error', '✗ ') - : isDone - ? currentTheme.fg('success', STATUS_BULLET) - : currentTheme.fg('text', STATUS_BULLET); + const marker = this.buildSingleSubagentMarker(phase); const labelText = formatSubagentLabel(this.subagentAgentName); 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 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); if (isDone) { - return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } const stats = currentTheme.dim(statsText); - return `${bullet}${label} ${status}${descriptionText}${stats}`; + return `${marker}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { @@ -1758,92 +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' - ? currentTheme.fg('error', '✗') - : activity.phase === 'done' - ? currentTheme.fg('success', '•') - : currentTheme.fg('text', '•'); - const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; - this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); - this.addSubToolOutputPreview(activity); - } - - if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { - const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); - if (errorLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('error', '└')} `, - ' ', - currentTheme.fg('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( - ` ${currentTheme.dim('◌')} `, - ' ', - currentTheme.dim(this.subagentThinkingText.trimEnd()), - THINKING_PREVIEW_LINES, - ), - ); - } - if (outputLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('text', '└')} `, - ' ', - currentTheme.fg('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 { - 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', - maxLines: RESULT_PREVIEW_LINES, - indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, - tail: activity.phase === 'ongoing', - }), + 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 = currentTheme.fg('primary', activity.name); - const argCol = keyArg ? currentTheme.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 { @@ -1866,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; @@ -1907,11 +1983,15 @@ export class ToolCallComponent extends Container { for (const line of lines) { this.addChild(new Text(line, 2, 0)); } - } else if (name === 'Bash' && this.result === undefined) { - // While a long-running Bash call is in-flight (args finalized, no result - // yet), surface its command in the body so the user can see what is - // running and expand it with ctrl+o. Once the result lands, buildContent's - // shellExecutionResultRenderer takes over command rendering. + } 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( @@ -1984,7 +2064,7 @@ export class ToolCallComponent extends Container { new ShellExecutionComponent({ command: cmd, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } @@ -2050,10 +2130,14 @@ export class ToolCallComponent extends Container { return; } - // Outputs that start with a `` 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('` 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 `` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('')) { return; } 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 04f20dc35..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,4 +1,4 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; 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 e619ff19d..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,6 +1,7 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +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'; import { PREVIEW_LINES } from './types'; @@ -44,6 +45,10 @@ export class TruncatedOutputComponent implements Component { // 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; @@ -52,8 +57,9 @@ export class TruncatedOutputComponent implements Component { this.expandHint = options.expandHint ?? true; this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; this.textComponent = new Text( - options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), this.indent, 0, ); 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 94161d1a8..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,4 +1,4 @@ -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 { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; 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 1eeba55e2..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,8 +4,8 @@ * 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 { @@ -30,9 +30,19 @@ 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 { @@ -121,8 +131,7 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); @@ -136,6 +145,91 @@ 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 accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); @@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const value = (text: string) => currentTheme.fg('text', text); const muted = (text: string) => currentTheme.fg('textDim', text); const errorStyle = (text: string) => currentTheme.fg('error', text); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -197,6 +289,17 @@ 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; } 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 0e9b2c32c..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,7 +2,7 @@ * Renders a user message in the transcript. */ -import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +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'; 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 443f3aa74..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,4 +1,4 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@moonshot-ai/pi-tui'; import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; 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 9c4936fa2..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,10 +1,10 @@ -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'; 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 a3c959a6a..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,4 +1,4 @@ -import { Container, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index fdcd8714e..cd3329b55 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -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, @@ -150,6 +154,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. 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/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index d984f7082..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, diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts index ac1fd8c11..48dd1f8b8 100644 --- a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index db3717f83..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'; @@ -22,6 +23,12 @@ 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; @@ -66,6 +73,43 @@ 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(); }; @@ -243,10 +287,6 @@ export class EditorKeyboardController { host.track('undo'); }; - editor.onInsertNewline = () => { - host.track('shortcut_newline'); - }; - editor.onTextPaste = () => { host.track('shortcut_paste', { kind: 'text' }); }; @@ -283,6 +323,11 @@ export class EditorKeyboardController { this.pendingExit = null; } + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + private armPendingUndoEsc(): void { this.clearPendingUndoEsc(); const timer = setTimeout(() => { @@ -360,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 6abcba76a..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,4 +1,4 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, @@ -738,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 ); } @@ -984,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); } @@ -999,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); } diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index b4f23bc48..00ee5a64b 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -472,6 +472,7 @@ export class SessionReplayRenderer { 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, diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb620801e..ae5eac768 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -536,6 +536,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { @@ -559,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; @@ -723,13 +730,16 @@ export class StreamingUIController { 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(); } 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 6d08330c5..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, diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 90ed3c99e..6994b13b8 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,5 +1,5 @@ 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'; diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index 6f638aba0..15e3608f8 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -10,7 +10,7 @@ */ 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'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 32f41b403..62d1b2370 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,13 +1,6 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - deleteAllKittyImages, - type Component, - type Focusable, - getCapabilities, - Spacer, -} from '@earendil-works/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -20,6 +13,13 @@ import type { Session, } from '@moonshot-ai/kimi-code-sdk'; 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'; @@ -30,6 +30,7 @@ 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'; @@ -74,15 +75,15 @@ import { GoalCompletionMessageComponent, GoalSetMessageComponent, } from './components/messages/goal-panel'; -import { SkillActivationComponent } from './components/messages/skill-activation'; import { PluginCommandComponent } from './components/messages/plugin-command'; import { ShellRunComponent } from './components/messages/shell-run'; +import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; -import { ThinkingComponent } from './components/messages/thinking'; 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'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; @@ -134,12 +135,17 @@ import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attach import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; 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 { getTranscriptComponentEntry, 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, @@ -149,8 +155,6 @@ import { groupTurns, turnsToTrim, } from './utils/transcript-window'; -import { formatBashOutputForDisplay } from './utils/shell-output'; -import { nextTranscriptId } from './utils/transcript-id'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -216,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: {}, @@ -565,6 +570,9 @@ 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); @@ -764,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); } @@ -839,6 +871,10 @@ 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); } @@ -898,11 +934,11 @@ export class KimiTUI { this.showError('Cannot send input while session history is replaying.'); return; } - // Shell commands (`! …`) are not prompts — keep them out of input history - // so ↑ recall never surfaces a bare command stripped of its `!`. - if (!wasBashMode) { - void this.persistInputHistory(text); - } + // 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. @@ -955,6 +991,8 @@ export class KimiTUI { 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); @@ -1488,6 +1526,7 @@ export class KimiTUI { for (const dispose of this.reverseRpcDisposers) { dispose(); } + this.reverseRpcDisposers.length = 0; } private registerSessionHandlers(session: Session): void { @@ -1704,7 +1743,10 @@ export class KimiTUI { if (data.result === 'cancelled') { block.markCanceled(); } else { - block.markDone(data.tokensBefore, data.tokensAfter); + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } } return block; } @@ -1845,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 = []; @@ -1852,12 +1904,7 @@ export class KimiTUI { this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); - // Dispose disposable children (e.g. ShellRunComponent's 1s timer) before - // dropping them, so a /clear or session switch can't leak intervals that - // keep firing requestRender on a removed component. - for (const child of this.state.transcriptContainer.children) { - if (hasDispose(child)) child.dispose(); - } + this.disposeTranscriptChildren(); this.state.transcriptContainer.clear(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); @@ -1865,6 +1912,11 @@ 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); } private isTurnBoundaryComponent(child: Component): boolean { @@ -1904,6 +1956,15 @@ export class KimiTUI { 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 ( @@ -2231,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; } } @@ -2297,7 +2362,12 @@ export class KimiTUI { if (!isExpandable(child)) continue; child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } - this.state.ui.requestRender(); + // 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 { @@ -2386,7 +2456,8 @@ export class KimiTUI { 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.`; + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; } else { hint = `Moved ${detached} of ${targets.length} tasks to background.`; } @@ -2534,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 { 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 d03f309fa..1b53bce0c 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -8,7 +8,7 @@ * 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'; diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 6a9594f01..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,6 +10,7 @@ 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 { currentTheme, type Theme } from './theme'; @@ -51,6 +52,13 @@ export interface TUIState { 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; } @@ -68,7 +76,9 @@ export function createTUIState(options: KimiTUIOptions): TUIState { 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); + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); @@ -100,6 +110,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { 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 9615ffcfb..6dcdccdd1 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -48,6 +48,8 @@ export interface AppState { 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; @@ -117,6 +119,7 @@ export interface BackgroundAgentStatusData { export interface CompactionTranscriptData { readonly result?: 'cancelled'; + readonly summary?: string; readonly tokensBefore?: number; readonly tokensAfter?: number; readonly instruction?: string; 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(); - 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): 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/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/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/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts index 3cac6826b..a4b5099a7 100644 --- a/apps/kimi-code/src/tui/utils/tab-strip.ts +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -8,7 +8,7 @@ * visible, framed by `<`/`>` markers. */ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index ab6f1bff7..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'; 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 index 9a5a694cd..ed0083b6f 100644 --- a/apps/kimi-code/src/tui/utils/transcript-window.ts +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -29,13 +29,13 @@ export function readEnvInt(name: string, fallback: number): number { export const TRANSCRIPT_WINDOW_ENABLED = true; /** Keep the most recent N turns. `0` disables trimming. */ -export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 50); +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', 10); +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); 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/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 (`4;0m`). +const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\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/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 4518cf3e7..14fdc0190 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -412,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 04780bd26..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, @@ -164,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); }); @@ -243,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) => { + 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/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 04d6556cf..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), finalizeHeadlessRun: vi.fn(), log: { info: vi.fn(), @@ -80,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, }; @@ -215,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 () => { @@ -301,6 +304,34 @@ describe('main entry command handling', () => { 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/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 41673cc51..aafb99af9 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { @@ -208,6 +209,7 @@ describe('runPrompt', () => { model: 'k2', permission: 'auto', additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.session.setPermission).not.toHaveBeenCalled(); expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -215,6 +217,9 @@ 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(); }); @@ -333,6 +338,7 @@ describe('runPrompt', () => { model: 'kimi-code/k2.5', permission: 'auto', additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ model: 'kimi-code/k2.5' }), @@ -350,6 +356,7 @@ describe('runPrompt', () => { model: 'k2', permission: 'auto', additionalDirs: ['../shared', '/tmp/extra'], + drainAgentTasksOnStop: true, }); }); @@ -659,6 +666,37 @@ describe('runPrompt', () => { ); }); + it('flushes stream-json assistant output before waiting for background tasks', async () => { + let releaseWait: () => void = () => {}; + const waitGate = new Promise((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' }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 27f0bea57..eafc4a9e0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -199,7 +199,7 @@ describe('runShell', () => { 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', @@ -213,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'); @@ -240,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', diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 782ccbc92..b6ee71f95 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -374,6 +374,43 @@ describe('`kimi server run` background start', () => { 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 = ''; @@ -463,6 +500,79 @@ describe('`kimi server run` background start', () => { 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`', () => { @@ -738,6 +848,81 @@ describe('--allowed-host threading', () => { }); }); +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'); @@ -1027,6 +1212,19 @@ describe('spawnDaemonChild', () => { 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', () => { diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 0a5690566..4bc79289b 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -161,6 +161,7 @@ function installState(overrides: Partial = {}): UpdateInstal function tuiConfig(overrides: Partial = {}): TuiConfig { return { theme: 'auto', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -419,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')); @@ -578,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()); 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/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index b719da163..0bcae749a 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 1560f3d9a..ea59d4fae 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -330,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/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 76ffb0a7b..77f582a1b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -137,6 +137,9 @@ function makeHost({ availableModels: {}, availableProviders: {}, }, + editor: { + setDisablePasteBurst: vi.fn(), + }, theme: { palette: { success: '#00ff00', 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 fdb64ce46..b584c33d6 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -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/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts index 8f5724e5f..aecf815d9 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { currentTheme } from '#/tui/theme'; 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 440a4ad06..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; 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 ce5ddaf97..2fe6f3e52 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -144,3 +144,46 @@ describe('FooterComponent', () => { 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 index e8bea6504..ffdc5bcbe 100644 --- a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { MoonLoader } from '#/tui/components/chrome/moon-loader'; 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 20e4d112d..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 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 index 939242383..610ea23e2 100644 --- 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 @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; 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 612ec152f..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'; 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 d4ba77fa9..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,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -138,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/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index f4a9286f8..4f415bc32 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -50,6 +50,7 @@ describe('CompactionComponent', () => { expect(text).toContain('Compaction complete'); expect(text).not.toContain('Tip:'); + expect(text).not.toContain('Ctrl-O'); } finally { component.dispose(); } @@ -70,6 +71,83 @@ describe('CompactionComponent', () => { } }); + 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. 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 b46ba311c..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,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 { 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 068b7f00f..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; 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 547b30a66..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 { 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 76658bb56..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,5 +1,5 @@ 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'; @@ -363,7 +363,7 @@ describe('ModelSelectorComponent', () => { expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); }); - it('always-on effort models show an unsupported Off that cannot be selected', () => { + it('always-on effort models hide Off and clamp selection at the last effort', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { @@ -376,8 +376,8 @@ describe('ModelSelectorComponent', () => { }); const raw = picker.render(120).join('\n'); - // Off is rendered muted as unavailable, not as a selectable segment. - expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); + // 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 ]'); @@ -422,3 +422,25 @@ describe('ModelSelectorComponent', () => { 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 05a9b3bef..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 @@ -289,6 +289,87 @@ describe('plugins selector dialogs', () => { 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'); 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 12ca62ed7..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,4 +1,4 @@ -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'; 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 70f7dfe30..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,4 +1,4 @@ -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'; 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 a1f53b8d1..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,8 +3,8 @@ 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 { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -12,6 +12,7 @@ import { FileMentionProvider } from '#/tui/components/editor/file-mention-provid function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), terminal: { rows: 40, cols: 120 }, } as unknown as TUI; return new CustomEditor(tui); @@ -71,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => { getSuggestions: vi.fn( () => new Promise((resolve) => { - resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + resolveSuggestions = (items) => { + resolve({ items, prefix: '/' }); + }; }), ), applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), @@ -152,7 +155,8 @@ describe('CustomEditor slash argument completion refresh', () => { description: 'Add directory', getArgumentCompletions: (prefix) => { if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }]; - if (prefix === '/tmp/shared/') return [{ value: '/tmp/shared/child/', label: 'child/' }]; + if (prefix === '/tmp/shared/') + return [{ value: '/tmp/shared/child/', label: 'child/' }]; return null; }, }, @@ -586,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(); @@ -769,3 +760,127 @@ describe('CustomEditor bash mode file completion', () => { 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; + } { + 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 5e2fa6ef3..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,3 +1,4 @@ +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'; @@ -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', @@ -298,6 +310,56 @@ describe('FileMentionProvider', () => { ); }); + 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 {};'); @@ -332,14 +394,19 @@ describe('FileMentionProvider', () => { expect(overlapItems).toHaveLength(1); }); - 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.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(['@read'], 0, 5, { signal: ctrl() }); + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }, + ); it('filesystem fallback returns folders and excludes .git', async () => { mkdirSync(join(workDir, 'src')); 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 index 8a8a7ba00..c4147ab40 100644 --- 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 @@ -1,4 +1,4 @@ -import { visibleWidth, type SelectItem, type SelectListTheme } from '@earendil-works/pi-tui'; +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'; 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 8e7214e11..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 @@ -155,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)}`); 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 index d2bac1561..b6378f389 100644 --- a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -1,4 +1,4 @@ -import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; +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'; 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 index fc0e0ba30..adf4d3b0b 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; 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 485a171ba..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,5 @@ 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 { 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 8d42ca5e0..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,4 +1,4 @@ -import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import { Markdown, visibleWidth } from '@moonshot-ai/pi-tui'; import * as cliHighlight from 'cli-highlight'; import { describe, expect, it, vi } from 'vitest'; 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 fbc2efbc5..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; 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 316c30af7..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers'; 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 b5b89ab0d..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 6e5e261d8..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { CronMessageComponent } from '#/tui/components/messages/cron-message'; 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 128aa2cdd..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 @@ -112,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', @@ -131,11 +131,14 @@ describe('ShellExecutionComponent', () => { .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', @@ -144,7 +147,7 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, { expanded: true }, @@ -154,9 +157,9 @@ describe('ShellExecutionComponent', () => { .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/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7d27be8e2..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 @@ -68,6 +68,44 @@ 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({ version: '1.2.3', 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 40f609be1..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,4 +1,4 @@ -import { visibleWidth, 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'; 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 3b7e82469..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,4 +1,4 @@ -import { visibleWidth, 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'; @@ -186,7 +186,7 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line2\n'); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Using Bash'); + expect(out).toContain('Running a command'); expect(out).toContain('line1'); expect(out).toContain('line2'); }); @@ -209,12 +209,12 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Bash'); + expect(out).toContain('Ran a command'); expect(out).toContain('final-only'); expect(out).not.toContain('streamed-only'); }); - describe('in-flight Bash command preview (args finalized, no result yet)', () => { + describe('Bash command preview', () => { const longCommand = Array.from({ length: 15 }, (_, i) => `echo step${String(i + 1)}`).join( '\n', ); @@ -226,7 +226,7 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('Using Bash'); + expect(collapsed).toContain('Running a command'); expect(collapsed).toContain('echo step1'); expect(collapsed).toContain('echo step10'); expect(collapsed).not.toContain('echo step11'); @@ -238,7 +238,7 @@ describe('ToolCallComponent', () => { expect(expanded).toContain('echo step15'); }); - it('yields command rendering to the result renderer once the result lands', () => { + 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, @@ -249,16 +249,41 @@ describe('ToolCallComponent', () => { component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); - // Collapsed result view delegates to shellExecutionResultRenderer, which - // hides the command — so the in-flight buildCallPreview preview must be - // gone, otherwise the command would render twice when expanded. + // 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('Used Bash'); - expect(out).not.toContain('$ echo step1'); + 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 { + it('hides tool output bodies that start with a { const reminderOutput = '\nThe task tools have not been used recently.\n'; const component = new ToolCallComponent( @@ -275,7 +300,7 @@ describe('ToolCallComponent', () => { ); 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'); @@ -285,7 +310,7 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('task tools'); }); - it('hides { + it('hides { const component = new ToolCallComponent( { id: 'call_hidden_err', @@ -304,6 +329,29 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); + it('renders output that merely starts with a literal 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: 'literal text from a user file\nsecond line', + is_error: false, + }, + ); + + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('literal text from a user file'); + expect(out).toContain('second line'); + }); + it('renders AgentSwarm results as a one-line summary without raw XML', () => { const output = [ '', @@ -929,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' }); @@ -950,7 +999,7 @@ 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'); @@ -1000,7 +1049,7 @@ describe('ToolCallComponent', () => { component.dispose(); }); - it('keeps the single subagent tool area to the latest four activities', () => { + it('summarizes subagent tools as a count plus the current tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1030,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( @@ -1075,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( @@ -1098,24 +1150,17 @@ describe('ToolCallComponent', () => { 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', () => { @@ -1146,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( @@ -1168,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( @@ -1202,6 +1247,7 @@ describe('ToolCallComponent', () => { agentName: 'explore', runInBackground: false, }); + // A finished recognized tool: its output body never reaches the window. component.appendSubToolCall({ id: 'sub_mixed:read', name: 'Read', @@ -1212,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'); }); @@ -1254,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 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: `` }, { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, { type: 'text', text: '' }, - { 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 bcab35a48..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; @@ -74,4 +74,17 @@ describe('TruncatedOutputComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('renders output verbatim, including literal 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( + 'literal text from a user file\n', + { expanded: true, isError: false }, + ); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('literal text from a user file'); + expect(out).toContain(''); + }); }); 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 5540b9b75..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,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; @@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -48,6 +48,142 @@ 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'], 'primary'); const output = component.render(80).map(strip); 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 23ec702ae..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,4 +1,4 @@ -import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; 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 9f067b168..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,6 +1,6 @@ import { pathToFileURL } from 'node:url'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; 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 56601592d..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,4 +1,4 @@ -import { Text, visibleWidth } 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'; diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 21c4f4b6c..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,6 +125,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -124,6 +137,7 @@ command = " " await saveTuiConfig( { theme, + disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts index 0c69ebac9..2bc4c5b4e 100644 --- a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { 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(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + readonly track: ReturnType; + pasteImage(): Promise; +} + +function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness { + const editor: Record 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)(); + }, + }; +} + +async function solidPng(width: number, height: number): Promise { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +async function solidJpeg(width: number, height: number): Promise { + 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; + 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 index 5bc96cd74..090d47d50 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -15,7 +15,10 @@ interface Harness { } function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { - const editor: Record unknown) | undefined> = {}; + const editor: Record 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 () => {}) }; @@ -119,3 +122,80 @@ describe('EditorKeyboardController double-Esc undo', () => { expect(session.cancel).toHaveBeenCalled(); }); }); + +describe('EditorKeyboardController shell history recall', () => { + type Recall = (entry: string, direction: 1 | -1) => string | undefined; + type Mock = ReturnType; + + 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 d02578f30..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 @@ -54,6 +54,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], + queuedMessageDispatchPending: false, theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: 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', @@ -234,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) => { + 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/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 = /