diff --git a/.changeset/animated-terminal-title-spinner.md b/.changeset/animated-terminal-title-spinner.md new file mode 100644 index 0000000000..2efd6fa542 --- /dev/null +++ b/.changeset/animated-terminal-title-spinner.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an animated terminal-title indicator while the agent is busy. Set `KIMI_TITLE_SPINNER=off` to disable it. diff --git a/.changeset/bash-mode-large-paste.md b/.changeset/bash-mode-large-paste.md new file mode 100644 index 0000000000..c384ff1972 --- /dev/null +++ b/.changeset/bash-mode-large-paste.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Enter shell mode when pasting a large `!` command into an empty prompt, even if the paste is folded into a marker. diff --git a/.changeset/calm-ravens-share.md b/.changeset/calm-ravens-share.md new file mode 100644 index 0000000000..27825c1c71 --- /dev/null +++ b/.changeset/calm-ravens-share.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep CLI shutdown within one bounded timeout when multiple telemetry pipelines are active. diff --git a/.changeset/context-usage-command.md b/.changeset/context-usage-command.md new file mode 100644 index 0000000000..3d80cd88d7 --- /dev/null +++ b/.changeset/context-usage-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /context slash command to show a Claude Code-style context usage breakdown with estimated tokens per category (system prompt, tools, MCP tools, memory files, skills, messages). Run /context to see it. diff --git a/.changeset/dedup-register-rejected-calls.md b/.changeset/dedup-register-rejected-calls.md new file mode 100644 index 0000000000..1281d6e3f8 --- /dev/null +++ b/.changeset/dedup-register-rejected-calls.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Count validation-rejected tool calls toward the repeat breaker so reminders fire at 3/5/8 and the turn force-stops at 12. diff --git a/.changeset/dedupe-reminder-survives-truncation.md b/.changeset/dedupe-reminder-survives-truncation.md new file mode 100644 index 0000000000..48af6a9d7d --- /dev/null +++ b/.changeset/dedupe-reminder-survives-truncation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep repeat-call warnings visible when oversized tool results are offloaded. diff --git a/.changeset/disabled-skills-config.md b/.changeset/disabled-skills-config.md new file mode 100644 index 0000000000..d9cff1a4d4 --- /dev/null +++ b/.changeset/disabled-skills-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add `disabled_skills` config to fully hide selected skill names from Kimi (model listing, Skill tool, slash menu, and activation). Set `disabled_skills = ["name"]` in `config.toml`, then run `/reload`. diff --git a/.changeset/fix-web-stream-text-wrapping.md b/.changeset/fix-web-stream-text-wrapping.md new file mode 100644 index 0000000000..98ad1d194d --- /dev/null +++ b/.changeset/fix-web-stream-text-wrapping.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix streamed assistant text rendering with unintended line breaks between chunks. diff --git a/.changeset/localhost-bind-classification.md b/.changeset/localhost-bind-classification.md new file mode 100644 index 0000000000..16f9c75045 --- /dev/null +++ b/.changeset/localhost-bind-classification.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Classify localhost bind hosts case-insensitively. diff --git a/.changeset/monitor-tool.md b/.changeset/monitor-tool.md new file mode 100644 index 0000000000..c01f713eb0 --- /dev/null +++ b/.changeset/monitor-tool.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a `Monitor` tool: run a self-filtering shell command in the background and receive each new stdout line as a notification (with batching and an auto-stop volume cap). Stop with `TaskStop` or let it time out; `persistent` watches run until the session ends. diff --git a/.changeset/mouse-select-editor.md b/.changeset/mouse-select-editor.md new file mode 100644 index 0000000000..a8cf2bd8c7 --- /dev/null +++ b/.changeset/mouse-select-editor.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": minor +--- + +Add text selection, replacement, deletion, rendering, and mouse-position mapping to the editor component. diff --git a/.changeset/node-sdk-v2-surface.md b/.changeset/node-sdk-v2-surface.md new file mode 100644 index 0000000000..e1c74c6ca2 --- /dev/null +++ b/.changeset/node-sdk-v2-surface.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add the v2 SDK surface for sessions, configuration, events, and global MCP management. diff --git a/.changeset/qualified-plugin-skills.md b/.changeset/qualified-plugin-skills.md new file mode 100644 index 0000000000..931520ded1 --- /dev/null +++ b/.changeset/qualified-plugin-skills.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix plugin-qualified Skill listing and invocation, and report ambiguous bare plugin Skill names. diff --git a/.changeset/quota-exhausted-fail-fast.md b/.changeset/quota-exhausted-fail-fast.md new file mode 100644 index 0000000000..b2405dc210 --- /dev/null +++ b/.changeset/quota-exhausted-fail-fast.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fail fast on quota/balance-exhausted HTTP 429 errors (e.g. Moonshot `exceeded_current_quota_error`, OpenAI `insufficient_quota`) instead of silently retrying for ~3 minutes. Transient rate-limit 429s keep the existing retry, backoff, and Retry-After behavior. diff --git a/.changeset/refresh-web-skill-menus.md b/.changeset/refresh-web-skill-menus.md new file mode 100644 index 0000000000..53b55fa83d --- /dev/null +++ b/.changeset/refresh-web-skill-menus.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Refresh loaded skill menus after configuration changes. diff --git a/.changeset/status-line-config.md b/.changeset/status-line-config.md new file mode 100644 index 0000000000..6aa1f81702 --- /dev/null +++ b/.changeset/status-line-config.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': minor +--- + +Customizable footer status line: compose built-in slots via `[status_line] items` in `tui.toml`, or render the first stdout line of a user script via `[status_line] command`. diff --git a/.changeset/strict-colorfgbg-background.md b/.changeset/strict-colorfgbg-background.md new file mode 100644 index 0000000000..f0efb75e96 --- /dev/null +++ b/.changeset/strict-colorfgbg-background.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Ignore malformed COLORFGBG background color indexes during terminal theme detection. diff --git a/.changeset/strict-vis-port-values.md b/.changeset/strict-vis-port-values.md new file mode 100644 index 0000000000..12ee4dd252 --- /dev/null +++ b/.changeset/strict-vis-port-values.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reject malformed port values for the session visualizer command. diff --git a/.changeset/strict-web-port-values.md b/.changeset/strict-web-port-values.md new file mode 100644 index 0000000000..ddc5099678 --- /dev/null +++ b/.changeset/strict-web-port-values.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reject malformed port values for the web server command. diff --git a/.changeset/terminal-mouse-selection.md b/.changeset/terminal-mouse-selection.md new file mode 100644 index 0000000000..545b9daf81 --- /dev/null +++ b/.changeset/terminal-mouse-selection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental mouse selection to the prompt editor. Set `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1` to enable it. diff --git a/.changeset/unify-page-title-moon-spinner.md b/.changeset/unify-page-title-moon-spinner.md new file mode 100644 index 0000000000..a37c7e42e4 --- /dev/null +++ b/.changeset/unify-page-title-moon-spinner.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Unify the running-session page title spinner with the chat MoonSpinner sequence and timing. diff --git a/.changeset/upload-no-size-cap.md b/.changeset/upload-no-size-cap.md new file mode 100644 index 0000000000..1afa1ea34f --- /dev/null +++ b/.changeset/upload-no-size-cap.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Remove the 50 MB size limit on file uploads to the built-in server, so large attachments (for example in the web UI) no longer fail with an upload-too-large error. Uploads now stream to disk instead of being buffered in memory. diff --git a/.changeset/validate-installed-plugin-records.md b/.changeset/validate-installed-plugin-records.md new file mode 100644 index 0000000000..b467f49e2c --- /dev/null +++ b/.changeset/validate-installed-plugin-records.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Report a malformed record in `plugins/installed.json` as a load error naming the offending entry, instead of handing the record to the plugin manager and crashing later on a missing field. diff --git a/.changeset/web-initial-file-mention.md b/.changeset/web-initial-file-mention.md new file mode 100644 index 0000000000..03c515f5f2 --- /dev/null +++ b/.changeset/web-initial-file-mention.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix file mentions returning no matches while drafting the first message in a workspace. diff --git a/.changeset/web-network-hints-fallback.md b/.changeset/web-network-hints-fallback.md new file mode 100644 index 0000000000..b23e29c30b --- /dev/null +++ b/.changeset/web-network-hints-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep `kkm web` usable when the host cannot enumerate network interfaces. diff --git a/.changeset/web-settings-accent-highlight.md b/.changeset/web-settings-accent-highlight.md new file mode 100644 index 0000000000..c2ef73228e --- /dev/null +++ b/.changeset/web-settings-accent-highlight.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Highlight selected settings options (theme, language, default permission) with the current accent color. diff --git a/.changeset/web-steer-capslock-fix.md b/.changeset/web-steer-capslock-fix.md new file mode 100644 index 0000000000..46fa41b2d0 --- /dev/null +++ b/.changeset/web-steer-capslock-fix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix Ctrl+S steering not triggering when CapsLock is on. diff --git a/.changeset/web-steer-queued-messages.md b/.changeset/web-steer-queued-messages.md new file mode 100644 index 0000000000..db2deac77a --- /dev/null +++ b/.changeset/web-steer-queued-messages.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Add "Steer now" buttons to the queued-message list to inject queued messages into the running turn — one message at a time or the whole queue from the queue header. Click the bolt button next to a queued message to steer it. diff --git a/.changeset/web-steer-telemetry.md b/.changeset/web-steer-telemetry.md new file mode 100644 index 0000000000..c8495a6630 --- /dev/null +++ b/.changeset/web-steer-telemetry.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Count steered messages sent from the web UI in usage telemetry, the same way steers from the TUI are counted. diff --git a/AGENTS.md b/AGENTS.md index 31b25124b4..e6fe240464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. @@ -25,9 +25,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. +- `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). +- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index.ts` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`, rebuilt from the Store on open and on compaction) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. ## Environment Requirements diff --git a/apps/kimi-code/KKM_RELEASE b/apps/kimi-code/KKM_RELEASE index 4091d47e7e..d7e7031743 100644 --- a/apps/kimi-code/KKM_RELEASE +++ b/apps/kimi-code/KKM_RELEASE @@ -1 +1 @@ -v0.29.2-kkm.1 +v0.29.2-kkm.2 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 781d9a641c..235b62e049 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.29.2-kkm.1", + "version": "0.29.2-kkm.2", "description": "KKM, a portable Kimi Code fork with curated upstream fixes", "license": "MIT", "author": "Moonshot AI", @@ -65,9 +65,9 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", - "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", - "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", - "dev:kap-server:multi": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:server": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server:multi": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs index 4beb2bc6f3..62a34029a4 100644 --- a/apps/kimi-code/scripts/dev-server-restart.mjs +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -45,7 +45,7 @@ function start() { console.error('[dev:server:restart] starting server…'); child = spawn(tsxBin, tsxArgs, { cwd: APP_ROOT, - env: process.env, + env: { ...process.env, KIMI_CODE_DEV_SERVER: '1' }, // Server does not read stdin; keep ours free for the Enter trigger. stdio: ['ignore', 'inherit', 'inherit'], }); diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index f3b2f10954..4f53508bae 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -1,14 +1,17 @@ /** - * Experimental agent-core-v2 engine gate for `kimi -p` (print mode). + * Experimental agent-core-v2 engine gate for the CLI surfaces. * - * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, print mode - * routes to the native agent-core-v2 runner instead of the default v1 - * harness (see `run-prompt.ts`). Read directly from the env (matching + * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p` + * (print mode) routes to the native agent-core-v2 runner (see + * `run-prompt.ts`) and the interactive TUI builds its harness through the + * SDK's v2-backed client (see `run-shell.ts`), both instead of the default + * v1 engine. The master switch also enables every experimental feature flag + * in the engine. Read directly from the env (matching * `cli/update/rollout.ts`) because the CLI must not depend on the core flag - * registry. Unset / any non-truthy value keeps the v1 harness. + * registry. Unset / any non-truthy value keeps the v1 path. * * Note: `kimi web` always boots kap-server (the agent-core-v2 engine - * server) — it no longer consults this switch. + * server) — it does not consult this switch. */ export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 6d8c8b97b2..0278620c0e 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -37,6 +37,10 @@ import { import type { PromptHarness, PromptSession } from './prompt-session'; import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; +import { + createTelemetryShutdownDeadline, + formatTelemetryShutdownWarning, +} from './telemetry-shutdown'; import { createKimiCodeHostIdentity } from './version'; /** @@ -162,7 +166,15 @@ export async function runPrompt( try { await restorePromptSessionPermission(); } finally { - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + const telemetryDeadline = createTelemetryShutdownDeadline( + CLI_SHUTDOWN_TIMEOUT_MS, + (error) => { + stderr.write(formatTelemetryShutdownWarning(error)); + }, + ); + await telemetryDeadline.run((remainingMs) => + shutdownTelemetry({ timeoutMs: remainingMs }), + ); await harness.close(); } })()); diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index a9e2e3fc8a..fcf383bb8f 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -4,9 +4,11 @@ import { join } from 'node:path'; import { createKimiHarness, + createKimiHarnessV2, flushDiagnosticLogsSync, log, type KimiHarness, + type KimiHarnessOptions, type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; import { @@ -29,6 +31,7 @@ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; +import { isKimiV2Enabled } from './experimental-v2'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; @@ -60,7 +63,7 @@ export async function runShell( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createKimiHarness({ + const harnessOptions: KimiHarnessOptions = { homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, @@ -76,7 +79,13 @@ export async function runShell( }); }, sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, - }); + }; + // Experimental agent-core-v2 route (same master switch as `kimi -p`): the + // harness is the SDK's v2-backed client, so the whole TUI runs on the + // agent-core-v2 engine. + const harness = isKimiV2Enabled() + ? createKimiHarnessV2(harnessOptions) + : createKimiHarness(harnessOptions); log.info('kimi-code starting', { version, uiMode: CLI_UI_MODE, diff --git a/apps/kimi-code/src/cli/sub/vis.ts b/apps/kimi-code/src/cli/sub/vis.ts index 2759b36a17..9081b803f1 100644 --- a/apps/kimi-code/src/cli/sub/vis.ts +++ b/apps/kimi-code/src/cli/sub/vis.ts @@ -114,17 +114,28 @@ export function registerVisCommand(parent: Command, overrides?: Partial sessionId: string | undefined, options: { port?: string; host?: string; open?: boolean }, ) => { - const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); await handleVis(createDefaultVisDeps(overrides), { open: options.open !== false, - ...(port === undefined || Number.isNaN(port) ? {} : { port }), - ...(options.host === undefined ? {} : { host: options.host }), - ...(sessionId === undefined ? {} : { sessionId }), + port: parseVisPort(options.port), + host: options.host, + sessionId, }); }, ); } +export function parseVisPort(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + if (!/^\d+$/.test(raw)) { + throw new Error(`error: invalid --port value: ${raw}`); + } + const port = Number.parseInt(raw, 10); + if (!Number.isFinite(port) || port < 0 || port > 65535) { + throw new Error(`error: invalid --port value: ${raw}`); + } + return port; +} + function createDefaultVisDeps(overrides: Partial = {}): VisDeps { return { getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), diff --git a/apps/kimi-code/src/cli/sub/web/networks.ts b/apps/kimi-code/src/cli/sub/web/networks.ts index 39ca7d084f..e434209243 100644 --- a/apps/kimi-code/src/cli/sub/web/networks.ts +++ b/apps/kimi-code/src/cli/sub/web/networks.ts @@ -23,7 +23,15 @@ export interface NetworkAddress { */ export function listNetworkAddresses(): NetworkAddress[] { const raw: NetworkAddress[] = []; - for (const entries of Object.values(networkInterfaces())) { + let interfaces: ReturnType; + try { + interfaces = networkInterfaces(); + } catch { + // Some restricted containers do not expose interface metadata. Network + // hints are optional, so keep the server usable and omit them. + return []; + } + for (const entries of Object.values(interfaces)) { for (const info of entries ?? []) { if (info.internal) { continue; diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 540ef778cf..2328b50cd7 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -8,10 +8,15 @@ * `startServer`). */ +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; -import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; +import { + createServerLogger, + startServer, + type ServerLogger, +} from '@moonshot-ai/kap-server'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; import { type Command } from 'commander'; @@ -23,6 +28,7 @@ import { openUrl as defaultOpenUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; +import { createTelemetryShutdownDeadline } from '../../telemetry-shutdown'; import { buildKimiDefaultHeaders, getHostPackageRoot, @@ -57,7 +63,7 @@ const WEB_ASSETS_DIR = 'dist-web'; interface RoutedServer { readonly address: string; readonly logger: ServerLogger; - close(): Promise; + close(options?: { readonly telemetryDeadlineMs?: number }): Promise; } export interface WebCliOptions extends ServerCliOptions { @@ -249,15 +255,23 @@ async function runServerInProcess( if (stopping) return; stopping = true; running?.logger.info({ reason }, 'server shutting down'); - try { - await running?.close(); - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); - } catch (error) { - running?.logger.error( - { err: error instanceof Error ? error : new Error(String(error)) }, - 'server shutdown error', - ); - } + const telemetryDeadline = createTelemetryShutdownDeadline( + CLI_SHUTDOWN_TIMEOUT_MS, + (error) => { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'telemetry shutdown error', + ); + }, + ); + await telemetryDeadline.run(async () => + running?.close({ + telemetryDeadlineMs: telemetryDeadline.expiresAtMs, + }), + ); + await telemetryDeadline.run((remainingMs) => + shutdownTelemetry({ timeoutMs: remainingMs }), + ); process.exit(0); } @@ -266,6 +280,12 @@ async function runServerInProcess( // logger, close }`, so adapt it to the `RoutedServer` surface the rest of // this runner consumes. const logger = createServerLogger({ level: options.logLevel }); + const webAssetsDir = serverWebAssetsDir(); + if (webAssetsDir === undefined) { + logger.info( + 'dev mode: web assets not built; starting the API server without the web UI', + ); + } const v2 = await startServer({ host: options.host, port: options.port, @@ -288,13 +308,13 @@ async function runServerInProcess( // requests (model, WebSearch, FetchURL) carry the same User-Agent + // X-Msh-* identity as direct CLI runs. seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), - webAssetsDir: serverWebAssetsDir(), + webAssetsDir, }); logger.info('serving the REST/WS API and the bundled web UI'); running = { address: `http://${v2.host}:${v2.port}`, logger, - close: () => v2.close(), + close: (closeOptions) => v2.close(closeOptions), }; track('server_started', { daemon: false }); @@ -315,8 +335,23 @@ async function runServerInProcess( }); } -function serverWebAssetsDir(): string { - return resolveServerWebAssetsDir(); +/** + * Resolve the web assets directory passed to kap-server. In dev mode + * (`KIMI_CODE_DEV_SERVER=1`, set by the repo's `dev:server` / `dev:kap-server*` + * scripts) a missing `dist-web` build is tolerated: the server starts API-only + * and the web UI is expected to come from the kimi-web Vite dev server. + * Outside dev mode the directory is always returned and kap-server keeps + * failing fast when the assets are missing. + */ +export function serverWebAssetsDir( + env: NodeJS.ProcessEnv = process.env, + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string | undefined { + const dir = resolveServerWebAssetsDir(nativeWebAssetsDir); + if (env['KIMI_CODE_DEV_SERVER'] === '1' && !existsSync(join(dir, 'index.html'))) { + return undefined; + } + return dir; } export function resolveServerWebAssetsDir( diff --git a/apps/kimi-code/src/cli/sub/web/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts index 79dfff7d41..e914a05026 100644 --- a/apps/kimi-code/src/cli/sub/web/shared.ts +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -95,6 +95,9 @@ function parseHost(raw: string | boolean | undefined): string { export function parsePort(raw: string | undefined, label: string, fallback: number): number { if (raw === undefined) return fallback; + if (!/^\d+$/.test(raw)) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n < 0 || n > 65535) { throw new Error(`error: invalid ${label} value: ${raw}`); diff --git a/apps/kimi-code/src/cli/telemetry-shutdown.ts b/apps/kimi-code/src/cli/telemetry-shutdown.ts new file mode 100644 index 0000000000..9c5083aaae --- /dev/null +++ b/apps/kimi-code/src/cli/telemetry-shutdown.ts @@ -0,0 +1,41 @@ +export interface TelemetryShutdownDeadline { + readonly expiresAtMs: number; + remainingMs(): number; + run(pipeline: (remainingMs: number) => Promise): Promise; +} + +const TELEMETRY_SHUTDOWN_WARNING_PREFIX = 'Warning: telemetry shutdown failed:'; + +export function formatTelemetryShutdownWarning(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `${TELEMETRY_SHUTDOWN_WARNING_PREFIX} ${message}\n`; +} + +/** + * One process-exit budget shared by every telemetry pipeline owned by a CLI + * entrypoint. Pipelines remain independent: each receives the current budget, + * and a failure never prevents the next owner from attempting its cleanup. + */ +export function createTelemetryShutdownDeadline( + timeoutMs: number, + onError: (error: unknown) => void, + now: () => number = Date.now, +): TelemetryShutdownDeadline { + const expiresAtMs = now() + timeoutMs; + const remainingMs = (): number => Math.max(0, expiresAtMs - now()); + return { + expiresAtMs, + remainingMs, + run: async (pipeline) => { + try { + await pipeline(remainingMs()); + } catch (error) { + try { + onError(error); + } catch { + // A broken stderr/logger boundary must not block later cleanup. + } + } + }, + }; +} diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 0f70a941e3..b1539b7137 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -83,6 +83,10 @@ import { raceWithTimeout, requireConfiguredModel, } from '../run-prompt'; +import { + createTelemetryShutdownDeadline, + formatTelemetryShutdownWarning, +} from '../telemetry-shutdown'; import { createKimiCodeHostIdentity } from '../version'; import { resolveOutputFormat } from '../options'; @@ -177,8 +181,17 @@ export async function runV2Print( try { await restorePermission(); } finally { + const telemetryDeadline = createTelemetryShutdownDeadline( + CLI_SHUTDOWN_TIMEOUT_MS, + (error) => { + stderr.write(formatTelemetryShutdownWarning(error)); + }, + ); if (telemetryService !== undefined) { - await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + const service = telemetryService; + await telemetryDeadline.run((remainingMs) => + raceWithTimeout(service.shutdown(), remainingMs), + ); } app.dispose(); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index ae6f1620f9..39d40bcfc3 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -37,7 +37,7 @@ import { showSettingsSelector, } from './config'; import { handleGoalCommand } from './goal'; -import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info'; import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; @@ -80,7 +80,7 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; -export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +export { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand } from './goal'; @@ -116,6 +116,7 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; + refreshTerminalMouseTracking(): void; // Session requireSession(): Session; @@ -328,6 +329,9 @@ async function handleBuiltInSlashCommand( case 'usage': void showUsage(host); return; + case 'context': + void showContextReport(host); + return; case 'status': void showStatusReport(host); return; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..82701dc63d 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,8 +1,9 @@ import { release as osRelease, type as osType } from 'node:os'; -import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; +import type { ContextBreakdownData, McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; +import { buildContextReportLines } from '../components/messages/context-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; import { @@ -183,6 +184,41 @@ export async function showMcpServers(host: SlashCommandHost): Promise { host.state.ui.requestRender(); } +export async function showContextReport(host: SlashCommandHost): Promise { + const session = host.requireSession(); + // The MCP status list is decorative — a failure there must not sink the report. + const [breakdownResult, mcpServers] = await Promise.all([ + loadContextBreakdown(host), + session.listMcpServers().catch(() => undefined), + ]); + const panel = new UsagePanelComponent( + () => + buildContextReportLines({ + model: host.state.appState.model, + breakdown: breakdownResult.breakdown, + mcpServers, + error: breakdownResult.error, + }), + 'primary', + ' Context ', + ); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +interface ContextBreakdownResult { + readonly breakdown?: ContextBreakdownData; + readonly error?: string; +} + +async function loadContextBreakdown(host: SlashCommandHost): Promise { + try { + return { breakdown: await host.requireSession().getContextBreakdown() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } +} + async function loadSessionUsageReport(host: SlashCommandHost): Promise { try { return { usage: await host.requireSession().getUsage() }; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 31b358ca50..0cb40aaeec 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -325,6 +325,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'context', + aliases: [], + description: 'Show context usage breakdown', + priority: 65, + availability: 'always', + }, { name: 'status', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..7e11d5f087 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -6,13 +6,17 @@ import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { - const tuiConfig = await loadTuiConfig(); + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); await applyReloadedTuiConfig(host, tuiConfig); host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise { - const tuiConfig = await loadTuiConfig(); + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); const session = host.session; if (session !== undefined) { @@ -23,6 +27,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); + host.refreshTerminalMouseTracking(); applyRuntimeConfig(host, config); await applyReloadedTuiConfig(host, tuiConfig); @@ -48,6 +53,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, notifications: config.notifications, upgrade: config.upgrade, + statusLine: config.statusLine, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..4bb8a75f9b 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -16,6 +16,10 @@ import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/danc import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; import { createGitStatusCache, formatGitBadgeBase, @@ -29,6 +33,8 @@ import { usagePercentFromRatio, } from '#/utils/usage/usage-format'; +const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const; + const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; @@ -191,6 +197,7 @@ export class FooterComponent implements Component { private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; + private statusLineRunner: StatusLineCommandRunner | null = null; /** * Non-terminal background-task counts split by kind so the footer can * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks @@ -208,6 +215,7 @@ export class FooterComponent implements Component { this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); } setState(state: AppState): void { @@ -217,9 +225,25 @@ export class FooterComponent implements Component { } this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); this.state = state; } + private syncStatusLineRunner(state: AppState): void { + const command = state.statusLine?.command ?? null; + if (command === null) { + this.statusLineRunner?.dispose(); + this.statusLineRunner = null; + return; + } + if (this.statusLineRunner?.command !== command) { + // A reload can swap one command for another; the old runner would + // otherwise keep executing the previous script until restart. + this.statusLineRunner?.dispose(); + this.statusLineRunner = new StatusLineCommandRunner(command, this.onRefresh); + } + } + /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -250,23 +274,123 @@ export class FooterComponent implements Component { const colors = currentTheme.palette; const state = this.state; - // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── - const left: string[] = []; + // ── Line 1: slots composed per status_line.items, or a user command ── + let line1: string; + let customLine: string | null = null; + if (this.statusLineRunner !== null) { + this.statusLineRunner.maybeRefresh(this.statusLinePayload()); + customLine = this.statusLineRunner.current(); + } + + if (customLine !== null) { + // status_line.command: the first stdout line takes over line 1. + line1 = chalk.hex(colors.text)(customLine); + } else { + const slots = this.buildSlots(colors); + const configured = this.state.statusLine?.items ?? null; + const order: readonly string[] = configured ?? DEFAULT_STATUS_LINE_ITEMS; + const left: string[] = []; + for (const slot of order) { + const pieces = slots[slot as keyof typeof slots]; + if (pieces !== undefined) left.push(...pieces); + } + + const leftLine = left.join(' '); + const leftWidth = visibleWidth(leftLine); + + // Rotating hint tips stay on the right unless they were given an + // inline slot in items (rendered above at their configured position) + // or the user dropped 'tips' from items. + let tipText = ''; + const tipsInline = order.includes('tips'); + const showTips = !tipsInline && (configured === null || configured.includes('tips')); + if (showTips) { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const gap = 2; + const remaining = Math.max(0, width - leftWidth - gap); + if (pair && visibleWidth(pair) <= remaining) { + tipText = pair; + } else if (primary && visibleWidth(primary) <= remaining) { + tipText = primary; + } + } + + if (tipText) { + const pad = width - leftWidth - visibleWidth(tipText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + } else if (leftWidth <= width) { + line1 = leftLine; + } else { + line1 = truncateToWidth(leftLine, width, '…'); + } + } + + // ── Line 2: transient hint (bottom-left) + context (right) ── + const contextText = formatContextStatus( + state.contextUsage, + state.contextTokens, + state.maxContextTokens, + ); + const contextWidth = visibleWidth(contextText); + let line2: string; + if (this.transientHint) { + const maxHintWidth = Math.max(0, width - contextWidth - 1); + const shownHint = + visibleWidth(this.transientHint) <= maxHintWidth + ? this.transientHint + : truncateToWidth(this.transientHint, maxHintWidth, '…'); + const hintWidth = visibleWidth(shownHint); + const pad = Math.max(0, width - hintWidth - contextWidth); + line2 = + chalk.hex(colors.warning).bold(shownHint) + + ' '.repeat(pad) + + chalk.hex(colors.text)(contextText); + } else { + const leftPad = Math.max(0, width - contextWidth); + line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } + + return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + } + + /** + * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, + * outside a git repo) yield an empty list so composition just skips them. + */ + private buildSlots(colors: ColorPalette): Record { + const state = this.state; + const slots: Record = { + mode: [], + goal: [], + model: [], + tasks: [], + cwd: [], + git: [], + tips: [], + }; + + { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const tip = pair ?? primary; + if (tip) slots['tips'] = [chalk.hex(colors.textMuted)(tip)]; + } + const modes: string[] = []; if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto')); if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); if (state.swarmMode) modes.push(chalk.hex(colors.accent).bold('swarm')); - if (modes.length > 0) left.push(modes.join(' ')); + if (modes.length > 0) slots['mode'] = [modes.join(' ')]; const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); - if (goalBadge !== null) left.push(goalBadge); + if (goalBadge !== null) slots['goal'] = [goalBadge]; const model = modelDisplayName(state); if (model) { const effort = state.thinkingEffort; const rawCurrentModel = state.availableModels[state.model]; - const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + const currentModel = + rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); // Only effort-capable models (those declaring support_efforts) show the // concrete effort; legacy boolean models keep the plain "thinking" suffix. const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; @@ -281,83 +405,50 @@ export class FooterComponent implements Component { if (isRainbowDancing()) { renderedModelLabel = renderDanceFooterModel(modelLabel); } - left.push(renderedModelLabel); + slots['model'] = [renderedModelLabel]; } - // Background-task badges sit immediately before cwd. `bash-*` tasks - // (shell processes) and `agent-*` tasks (background subagents) get - // separate badges so the user can distinguish them at a glance. + // Background-task badges. `bash-*` tasks (shell processes) and `agent-*` + // tasks (background subagents) stay separate so the user can tell them + // apart at a glance. + const taskBadges: string[] = []; if (this.backgroundBashTaskCount > 0) { const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; - left.push( + taskBadges.push( chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), ); } if (this.backgroundAgentCount > 0) { const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; - left.push( + taskBadges.push( chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), ); } + slots['tasks'] = taskBadges; const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); + if (cwd) slots['cwd'] = [chalk.hex(colors.textDim)(cwd)]; const git = this.gitCache.getStatus(); - if (git !== null) { - left.push(formatFooterGitBadge(git, colors)); - } - - const leftLine = left.join(' '); - const leftWidth = visibleWidth(leftLine); - - // Rotating hint tips, fill remaining space on line 1. - const { primary, pair } = tipsForIndex(currentTipIndex()); - const gap = 2; - const remaining = Math.max(0, width - leftWidth - gap); - let tipText = ''; - if (pair && visibleWidth(pair) <= remaining) { - tipText = pair; - } else if (primary && visibleWidth(primary) <= remaining) { - tipText = primary; - } + if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)]; - let line1: string; - if (tipText) { - const pad = width - leftWidth - visibleWidth(tipText); - line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); - } else if (leftWidth <= width) { - line1 = leftLine; - } else { - line1 = truncateToWidth(leftLine, width, '…'); - } - - // ── Line 2: transient hint (bottom-left) + context (right) ── - const contextText = formatContextStatus( - state.contextUsage, - state.contextTokens, - state.maxContextTokens, - ); - const contextWidth = visibleWidth(contextText); - let line2: string; - if (this.transientHint) { - const maxHintWidth = Math.max(0, width - contextWidth - 1); - const shownHint = - visibleWidth(this.transientHint) <= maxHintWidth - ? this.transientHint - : truncateToWidth(this.transientHint, maxHintWidth, '…'); - const hintWidth = visibleWidth(shownHint); - const pad = Math.max(0, width - hintWidth - contextWidth); - line2 = - chalk.hex(colors.warning).bold(shownHint) + - ' '.repeat(pad) + - chalk.hex(colors.text)(contextText); - } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); - } + return slots; + } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + private statusLinePayload(): StatusLinePayload { + const state = this.state; + return { + model: modelDisplayName(state), + cwd: state.workDir, + gitBranch: this.gitCache.getStatus()?.branch ?? null, + permissionMode: state.permissionMode, + planMode: state.planMode, + contextUsage: state.contextUsage, + contextTokens: state.contextTokens, + maxContextTokens: state.maxContextTokens, + sessionId: state.sessionId, + version: state.version, + }; } private syncGoalClock(goal: AppState['goal']): void { 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 b1677e87b2..c416dbf135 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -127,6 +127,7 @@ export class CustomEditor extends Editor { public onNonEscapeInput?: () => void; public onCtrlD?: () => void; public onCtrlC?: () => void; + public onCopySelection?: (text: string) => void; public onToggleToolExpand?: () => void; public onOpenExternalEditor?: () => void; public onCtrlS?: () => void; @@ -424,6 +425,11 @@ export class CustomEditor extends Editor { } if (matchesKey(normalized, Key.ctrl('c'))) { + const selectedText = this.getSelectedText(); + if (selectedText !== undefined) { + this.onCopySelection?.(selectedText); + return; + } this.onCtrlC?.(); return; } @@ -496,6 +502,10 @@ export class CustomEditor extends Editor { this.cancelAutocompleteActivity(); return; } + if (this.hasSelection()) { + this.clearSelection(); + return; + } this.onEscape?.(); return; } @@ -523,14 +533,15 @@ export class CustomEditor extends Editor { const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); - // Enter bash mode when `!...` is pasted into an empty prompt. The typed path - // above handles the single `!` keystroke; this catches bracketed / Ctrl-V - // pastes whose content starts with `!`. Strip the leading `!` so the buffer - // holds only the command, exactly like the typed path. - if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { - this.inputMode = 'bash'; - this.onInputModeChange?.('bash'); - this.setText(this.getText().slice(1)); + // Enter bash mode when `!...` is pasted into an empty prompt. Check + // getExpandedText() so folded large pastes still match on the leading `!`. + if (emptyPromptBeforeInput && this.inputMode === 'prompt') { + const expanded = this.getExpandedText(); + if (expanded.startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(expanded.slice(1)); + } } this.reopenAutocompleteAfterInput(); diff --git a/apps/kimi-code/src/tui/components/messages/context-panel.ts b/apps/kimi-code/src/tui/components/messages/context-panel.ts new file mode 100644 index 0000000000..53f3aa4eba --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/context-panel.ts @@ -0,0 +1,188 @@ +/** + * ContextPanelComponent — renders the `/context` report: a Claude Code-style + * context-usage panel with a block-grid visualization, model + context-window + * summary, and the estimated per-category token cost of the context + * (system prompt, tool schemas, MCP tools, memory files, skills, messages), + * with per-server / per-file / per-skill token detail under each section. + */ + +import type { + ContextBreakdownData, + McpServerInfo, +} from '@moonshot-ai/kimi-code-sdk'; + +import { formatTokenCount, ratioSeverity, safeUsageRatio } from '#/utils/usage/usage-format'; +import { currentTheme } from '#/tui/theme'; +import type { ColorToken } from '#/tui/theme'; + +const BLOCK_FILLED = '⛁'; +const BLOCK_EMPTY = '⛶'; +const GRID_COLS = 10; +const GRID_ROWS = 4; +const GRID_BLOCKS = GRID_COLS * GRID_ROWS; + +export interface ContextReportOptions { + readonly model: string; + readonly breakdown?: ContextBreakdownData; + readonly mcpServers?: readonly McpServerInfo[]; + readonly error?: string; +} + +function severityColor(sev: 'ok' | 'warn' | 'danger'): ColorToken { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function renderBlockGrid(used: number, max: number): string[] { + const emptyCell = currentTheme.fg('textDim', BLOCK_EMPTY); + if (max <= 0) { + return Array.from({ length: GRID_ROWS }, () => emptyCell.repeat(GRID_COLS)); + } + const ratio = safeUsageRatio(used / max); + const usedBlocks = Math.round(ratio * GRID_BLOCKS); + const filledCell = currentTheme.fg(severityColor(ratioSeverity(ratio)), BLOCK_FILLED); + const lines: string[] = []; + for (let row = 0; row < GRID_ROWS; row += 1) { + const cells: string[] = []; + for (let col = 0; col < GRID_COLS; col += 1) { + const index = row * GRID_COLS + col; + cells.push(index < usedBlocks ? filledCell : emptyCell); + } + lines.push(cells.join(' ')); + } + return lines; +} + +/** Percentage of the context window with one decimal ("1.2"), like Claude Code. */ +function percentOf(tokens: number, max: number): string { + return ((tokens / max) * 100).toFixed(1); +} + +function categoryLine(icon: string, color: ColorToken, label: string, value: string): string { + return ` ${currentTheme.fg(color, icon)} ${currentTheme.boldFg('text', label)}: ${value}`; +} + +/** "2.4k tokens (1.2%)" — percent of the context window, or plain tokens when unknown. */ +function tokenValue(tokens: number, max: number): string { + const muted = (text: string) => currentTheme.fg('textDim', text); + const base = `${currentTheme.fg('text', formatTokenCount(tokens))} ${muted('tokens')}`; + return max > 0 ? `${base} ${muted(`(${percentOf(tokens, max)}%)`)}` : base; +} + +/** "~555 tokens" for detail lines, where the value is always an estimate. */ +function estimateValue(tokens: number): string { + return currentTheme.fg('textDim', `~${formatTokenCount(tokens)} tokens`); +} + +function buildMcpServerLines( + servers: readonly McpServerInfo[] | undefined, + breakdown: ContextBreakdownData, +): string[] { + if (servers === undefined || servers.length === 0) return []; + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const tokensByServer = new Map(breakdown.mcpServers.map((server) => [server.name, server.tokens])); + const lines: string[] = ['', currentTheme.boldFg('primary', `MCP servers · /mcp`)]; + const sorted = servers.toSorted((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < sorted.length; i += 1) { + const server = sorted[i]!; + const isLast = i === sorted.length - 1; + const branch = isLast ? '└' : '├'; + const statusBadge = server.status === 'connected' ? currentTheme.fg('success', '●') : muted('○'); + const tokens = tokensByServer.get(server.name); + lines.push( + ` ${branch} ${statusBadge} ${value(server.name)} ${muted( + `(${server.toolCount} tool${server.toolCount === 1 ? '' : 's'})`, + )}${tokens === undefined ? '' : ` ${estimateValue(tokens)}`}`, + ); + } + return lines; +} + +function buildMemoryFileLines(breakdown: ContextBreakdownData): string[] { + const files = breakdown.memoryFileEntries; + if (files.length === 0) return []; + const value = (text: string) => currentTheme.fg('text', text); + const lines: string[] = ['', currentTheme.boldFg('roleUser', `Memory files`)]; + for (let i = 0; i < files.length; i += 1) { + const file = files[i]!; + const branch = i === files.length - 1 ? '└' : '├'; + lines.push(` ${branch} ${value(file.path)} ${estimateValue(file.tokens)}`); + } + return lines; +} + +function buildSkillLines(breakdown: ContextBreakdownData): string[] { + const skills = breakdown.skillEntries; + if (skills.length === 0) return []; + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const lines: string[] = ['', currentTheme.boldFg('shellMode', `Skills · /skills`)]; + const sorted = skills.toSorted((a, b) => a.name.localeCompare(b.name)); + for (let i = 0; i < sorted.length; i += 1) { + const skill = sorted[i]!; + const branch = i === sorted.length - 1 ? '└' : '├'; + lines.push( + ` ${branch} ${value(skill.name)} ${muted(`[${skill.source}]`)} ${estimateValue(skill.tokens)}`, + ); + } + return lines; +} + +export function buildContextReportLines(options: ContextReportOptions): string[] { + const accent = (text: string) => currentTheme.boldFg('primary', text); + const muted = (text: string) => currentTheme.fg('textDim', text); + const value = (text: string) => currentTheme.fg('text', text); + const errorStyle = (text: string) => currentTheme.fg('error', text); + + const lines: string[] = [accent('Context Usage')]; + + if (options.error !== undefined || options.breakdown === undefined) { + lines.push(errorStyle(` ${options.error ?? 'Context breakdown unavailable.'}`)); + return lines; + } + const breakdown = options.breakdown; + + const modelName = options.model.length > 0 ? options.model : muted('No model selected'); + const grid = renderBlockGrid(breakdown.usedTokens, breakdown.maxContextTokens); + const hasWindow = breakdown.maxContextTokens > 0; + const tokenLine = hasWindow + ? `${value(formatTokenCount(breakdown.usedTokens))}/${value( + formatTokenCount(breakdown.maxContextTokens), + )} ${muted('tokens')} ${muted(`(${percentOf(breakdown.usedTokens, breakdown.maxContextTokens)}%)`)}` + : `${value(formatTokenCount(breakdown.usedTokens))} ${muted('tokens')}`; + + // Right-align the summary text next to the first two grid rows. + lines.push(` ${grid[0]} ${modelName}`); + lines.push(` ${grid[1]} ${tokenLine}`); + lines.push(` ${grid[2]}`); + lines.push(` ${grid[3]}`); + + lines.push(''); + lines.push(muted(' Estimated usage by category')); + + const max = breakdown.maxContextTokens; + const freeTokens = hasWindow ? Math.max(0, max - breakdown.usedTokens) : 0; + + lines.push(categoryLine('⛁', 'warning', 'System prompt', tokenValue(breakdown.systemPrompt, max))); + lines.push(categoryLine('⛁', 'accent', 'System tools', tokenValue(breakdown.systemTools, max))); + + const mcpToolCount = options.mcpServers?.reduce((sum, server) => sum + server.toolCount, 0) ?? 0; + const mcpSuffix = + mcpToolCount > 0 + ? muted(` · ${mcpToolCount} tool${mcpToolCount === 1 ? '' : 's'}`) + : ''; + lines.push( + categoryLine('⛁', 'primary', 'MCP tools', tokenValue(breakdown.mcpTools, max) + mcpSuffix), + ); + + lines.push(categoryLine('⛁', 'roleUser', 'Memory files', tokenValue(breakdown.memoryFiles, max))); + lines.push(categoryLine('⛁', 'shellMode', 'Skills', tokenValue(breakdown.skills, max))); + lines.push(categoryLine('⛁', 'text', 'Messages', tokenValue(breakdown.messages, max))); + lines.push(categoryLine('⛶', 'textDim', 'Free space', hasWindow ? tokenValue(freeTokens, max) : muted('unknown'))); + + lines.push(...buildMcpServerLines(options.mcpServers, breakdown)); + lines.push(...buildMemoryFileLines(breakdown)); + lines.push(...buildSkillLines(breakdown)); + + return lines; +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index cd3329b552..36f43ba07b 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,6 +30,27 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); +export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; + +export const StatusLineFileConfigSchema = z.object({ + items: z.array(z.string()).optional(), + command: z.string().optional(), +}); + +export const StatusLineConfigSchema = z.object({ + /** Ordered built-in slots for footer line 1; null means the default layout. */ + items: z.array(z.enum(STATUS_LINE_ITEMS)).nullable(), + /** User command whose first stdout line replaces footer line 1; null disables. */ + command: z.string().nullable(), +}); +export type StatusLineConfig = z.infer; + +export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { + items: null, + command: null, +}; + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), @@ -49,6 +70,7 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), + status_line: StatusLineFileConfigSchema.optional(), }); export const TuiConfigSchema = z.object({ @@ -57,6 +79,9 @@ export const TuiConfigSchema = z.object({ editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + statusLine: StatusLineConfigSchema.optional(), }); export type TuiConfigFileShape = z.infer; @@ -79,6 +104,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, + statusLine: DEFAULT_STATUS_LINE_CONFIG, }); /** @@ -100,7 +126,10 @@ export function getTuiConfigPath(): string { return join(getDataDir(), 'tui.toml'); } -export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Promise { +export async function loadTuiConfig( + filePath: string = getTuiConfigPath(), + warn?: (message: string) => void, +): Promise { if (!existsSync(filePath)) { await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); return DEFAULT_TUI_CONFIG; @@ -108,19 +137,22 @@ export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Prom try { const text = await readFile(filePath, 'utf-8'); - return parseTuiConfig(text); + return parseTuiConfig(text, warn); } catch { throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); } } -export function parseTuiConfig(tomlText: string): TuiConfig { +export function parseTuiConfig( + tomlText: string, + warn?: (message: string) => void, +): TuiConfig { if (tomlText.trim().length === 0) { return DEFAULT_TUI_CONFIG; } const raw = parseToml(tomlText) as Record; const parsed = TuiConfigFileSchema.parse(raw); - return normalizeTuiConfig(parsed); + return normalizeTuiConfig(parsed, warn); } export async function saveTuiConfig( @@ -131,8 +163,26 @@ export async function saveTuiConfig( await writeFile(filePath, renderTuiConfig(config), 'utf-8'); } -export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { +export function normalizeTuiConfig( + config: TuiConfigFileShape, + warn: (message: string) => void = (message) => { + // oxlint-disable-next-line no-console + console.warn(message); + }, +): TuiConfig { const command = config.editor?.command?.trim(); + const statusLineCommand = config.status_line?.command?.trim(); + const knownItems = new Set(STATUS_LINE_ITEMS); + const statusLineItems = + config.status_line?.items + ?.filter((item) => { + const known = knownItems.has(item); + if (!known) { + warn(`[tui.toml] ignoring unknown status_line item: ${item}`); + } + return known; + }) + .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -145,10 +195,39 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { upgrade: { autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, }, + statusLine: { + items: statusLineItems, + command: + statusLineCommand === undefined || statusLineCommand.length === 0 + ? null + : statusLineCommand, + }, }); } export function renderTuiConfig(config: TuiConfig): string { + // An active status_line must round-trip: any preference save rewrites the + // whole file, so the section is emitted live when set and left as a + // commented-out guide when unset. + const statusItems = config.statusLine?.items; + const statusCommand = config.statusLine?.command; + const statusLines: string[] = []; + if (statusItems !== null && statusItems !== undefined) { + statusLines.push(`items = ${JSON.stringify(statusItems)}`); + } + if (statusCommand) { + statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); + } + const statusSection = + statusLines.length > 0 + ? `[status_line]\n${statusLines.join('\n')}\n` + : `# [status_line] +# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} +# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} +# Or render your own: a command whose first stdout line replaces footer line 1. +# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. +# command = "~/.kimi-code/statusline.sh" +`; return `# ~/.kimi-code/tui.toml # Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. @@ -165,7 +244,8 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false -`; + +${statusSection}`; } function escapeTomlBasicString(value: string): string { diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts index ec87a07803..6ab123b445 100644 --- a/apps/kimi-code/src/tui/constant/terminal.ts +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -17,6 +17,12 @@ export const TERMINAL_FOCUS_OUT = `${ESC}[O`; export const ENABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004h`; export const DISABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004l`; +// Xterm SGR mouse reporting. Button-event tracking reports drag motion while +// a button is held; SGR mode keeps coordinates unambiguous in wide terminals. +export const ENABLE_TERMINAL_MOUSE_REPORTING = `${ESC}[?1002h${ESC}[?1006h`; +export const DISABLE_TERMINAL_MOUSE_REPORTING = + `${ESC}[?1006l${ESC}[?1003l${ESC}[?1002l${ESC}[?1000l`; + // Standard OSC 11 background-color query. The response regex intentionally // allows a missing leading ESC because terminals can echo replies alongside // other raw input, but it requires an OSC terminator so fragmented color diff --git a/apps/kimi-code/src/tui/constant/title-spinners.ts b/apps/kimi-code/src/tui/constant/title-spinners.ts new file mode 100644 index 0000000000..935c13b3c6 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/title-spinners.ts @@ -0,0 +1,57 @@ +/** + * Terminal-title spinner styles (OSC 0). + * + * While the agent is busy, the terminal title is prefixed with an animated + * glyph so progress stays visible on the terminal tab even when the window is + * unfocused. The active style is resolved once from `KIMI_TITLE_SPINNER` + * (default: `moon`). + * + * Each cycling style is a self-contained frame loop in `TITLE_SPINNER_STYLES`; + * adding or removing a style only touches that registry. Two special ids are + * not registry entries: `phase` maps the streaming phase to a static glyph + * (no animation timer), and `off` restores the plain static title. + */ + +export interface TitleSpinnerStyle { + readonly frames: readonly string[]; + readonly interval: number; +} + +export const TITLE_SPINNER_ENV = 'KIMI_TITLE_SPINNER'; + +export const DEFAULT_TITLE_SPINNER_ID = 'moon'; +export const OFF_TITLE_SPINNER_ID = 'off'; +export const PHASE_TITLE_SPINNER_ID = 'phase'; + +/** Cycling spinner styles. Entries are isolated; ordering is irrelevant. */ +export const TITLE_SPINNER_STYLES: Readonly> = { + moon: { frames: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], interval: 120 }, + sparkle: { frames: ['✦', '✧', '✩', '✧'], interval: 180 }, + braille: { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], interval: 80 }, + orbit: { frames: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], interval: 100 }, + pulse: { + frames: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂'], + interval: 90, + }, + line: { frames: ['|', '/', '-', '\\'], interval: 110 }, + hourglass: { frames: ['⧗', '⧖'], interval: 400 }, +}; + +/** Glyph shown per streaming phase by the `phase` style (no animation timer). */ +export const PHASE_TITLE_GLYPHS: Readonly> = { + waiting: '◌', + thinking: '✦', + composing: '≋', + shell: '▸', +}; + +/** Resolve a raw env value to a valid spinner id, falling back to the default. */ +export function resolveTitleSpinnerId(raw: string | undefined): string { + const id = raw?.trim().toLowerCase(); + if (id === OFF_TITLE_SPINNER_ID) return OFF_TITLE_SPINNER_ID; + if (id === PHASE_TITLE_SPINNER_ID) return PHASE_TITLE_SPINNER_ID; + // Own-property check only: an inherited key such as `constructor` or + // `toString` must not be accepted as a style (its value has no `frames`). + if (id !== undefined && Object.hasOwn(TITLE_SPINNER_STYLES, id)) return id; + return DEFAULT_TITLE_SPINNER_ID; +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 76d0363f80..c59893d467 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -2,6 +2,7 @@ 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 { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { parseImageMeta } from '#/utils/image/image-mime'; import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; @@ -55,6 +56,8 @@ export interface EditorKeyboardHost { handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + suspendTerminalMouseTracking(): void; + refreshTerminalMouseTracking(): void; } export class EditorKeyboardController { @@ -120,6 +123,10 @@ export class EditorKeyboardController { this.clearPendingUndoEsc(); }; + editor.onCopySelection = (text: string) => { + void copyTextToClipboard(text).catch(() => undefined); + }; + editor.onCtrlC = () => { if (host.cancelInFlight !== undefined) { const cancel = host.cancelInFlight; @@ -512,6 +519,7 @@ export class EditorKeyboardController { } this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); + this.host.suspendTerminalMouseTracking(); state.ui.stop(); await new Promise((resolve) => { setImmediate(resolve); @@ -529,6 +537,7 @@ export class EditorKeyboardController { process.stdin.pause(); } state.ui.start(); + this.host.refreshTerminalMouseTracking(); state.ui.setFocus(state.editor); state.ui.requestRender(true); this.host.setExternalEditorRunning(false); diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 6994b13b86..ef8cf922a5 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -18,6 +18,8 @@ export interface TasksBrowserHost { readonly session: Session | undefined; showError(msg: string): void; setTasksBrowser(value: TasksBrowserState | undefined): void; + suspendTerminalMouseTracking(): void; + refreshTerminalMouseTracking(): void; } export type TasksBrowserState = { @@ -83,6 +85,7 @@ export class TasksBrowserController { ); const savedChildren = [...state.ui.children]; + this.host.suspendTerminalMouseTracking(); state.ui.clear(); state.ui.addChild(component); state.ui.setFocus(component); @@ -125,6 +128,7 @@ export class TasksBrowserController { } this.host.setTasksBrowser(undefined); state.ui.setFocus(state.editor); + this.host.refreshTerminalMouseTracking(); state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts new file mode 100644 index 0000000000..8fcf95622a --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts @@ -0,0 +1,108 @@ +import { MAX_TERMINAL_TITLE_LENGTH } from '#/tui/constant/terminal'; +import { + OFF_TITLE_SPINNER_ID, + PHASE_TITLE_GLYPHS, + PHASE_TITLE_SPINNER_ID, + resolveTitleSpinnerId, + TITLE_SPINNER_ENV, + TITLE_SPINNER_STYLES, + type TitleSpinnerStyle, +} from '#/tui/constant/title-spinners'; + +const TITLE_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +export interface TerminalTitleSpinnerHost { + /** Write the terminal title (OSC 0). */ + setTitle(label: string): void; + /** Static title shown when idle (session title or product name). */ + staticTitle(): string; + /** Whether the agent is currently busy (streaming a turn or compacting). */ + isBusy(): boolean; + /** Current streaming phase. */ + streamingPhase(): string; +} + +/** + * Owns the terminal-title busy indicator: resolves the spinner style once, + * animates the title while the agent is busy, and restores the static title + * when idle or on shutdown. `KimiTUI` only calls `sync()` when the busy state + * changes and `dispose()` on teardown; all style, timer, and frame state lives + * here so the behavior stays independently testable. + */ +export class TerminalTitleSpinnerController { + private readonly host: TerminalTitleSpinnerHost; + private readonly spinnerId: string; + private timer: ReturnType | null = null; + private frame = 0; + + constructor( + host: TerminalTitleSpinnerHost, + spinnerEnv: string | undefined = process.env[TITLE_SPINNER_ENV], + ) { + this.host = host; + this.spinnerId = resolveTitleSpinnerId(spinnerEnv); + } + + /** Reflect the current busy state on the terminal title. */ + sync(): void { + if (!this.host.isBusy() || this.spinnerId === OFF_TITLE_SPINNER_ID) { + this.stop(); + this.host.setTitle(this.host.staticTitle()); + return; + } + if (this.spinnerId === PHASE_TITLE_SPINNER_ID) { + this.host.setTitle(this.phaseTitle()); + return; + } + const style = TITLE_SPINNER_STYLES[this.spinnerId]; + if (style === undefined) { + this.host.setTitle(this.host.staticTitle()); + return; + } + this.host.setTitle(this.frameTitle(style)); + this.timer ??= setInterval(() => { + this.frame = (this.frame + 1) % style.frames.length; + this.host.setTitle(this.frameTitle(style)); + }, style.interval); + } + + /** Stop animating and restore the static title (called on shutdown). */ + dispose(): void { + this.stop(); + this.host.setTitle(this.host.staticTitle()); + } + + private stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + private frameTitle(style: TitleSpinnerStyle): string { + const frame = style.frames[this.frame % style.frames.length] ?? ''; + return this.fitTitle(`${frame} ${this.host.staticTitle()}`); + } + + private phaseTitle(): string { + const phase = this.host.streamingPhase(); + const base = this.host.staticTitle(); + // Own-property check only: an inherited key such as `constructor` must + // not be stringified into the title. + const glyph = Object.hasOwn(PHASE_TITLE_GLYPHS, phase) ? PHASE_TITLE_GLYPHS[phase] : undefined; + return glyph === undefined ? base : this.fitTitle(`${glyph} ${base}`); + } + + /** Keep the composed title within the terminal-title cap. */ + private fitTitle(title: string): string { + if (title.length <= MAX_TERMINAL_TITLE_LENGTH) return title; + // Truncate on grapheme boundaries so multi-code-point characters (emoji, + // flags, ZWJ sequences) are dropped whole rather than split mid-cluster. + let fitted = ''; + for (const { segment } of TITLE_SEGMENTER.segment(title)) { + if (fitted.length + segment.length > MAX_TERMINAL_TITLE_LENGTH) break; + fitted += segment; + } + return fitted; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 72a9a74439..eafb6d148c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -110,6 +110,7 @@ import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; +import { TerminalTitleSpinnerController } from './controllers/terminal-title-spinner'; import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; @@ -145,6 +146,7 @@ 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 { installEditorMouseTracking } from './utils/editor-mouse'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; @@ -241,6 +243,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, + statusLine: input.tuiConfig.statusLine, availableModels: {}, availableProviders: {}, sessionTitle: null, @@ -326,6 +329,7 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private terminalMouseTrackingDispose: (() => void) | undefined; private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; @@ -352,6 +356,7 @@ export class KimiTUI { readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; + readonly titleSpinner: TerminalTitleSpinnerController; /** Timer that auto-clears the one-shot "moved to background" footer hint. */ private detachHintClearTimer: ReturnType | undefined; @@ -436,6 +441,16 @@ export class KimiTUI { this.tasksBrowserController = new TasksBrowserController(this); this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); this.editorKeyboard.install(); + this.titleSpinner = new TerminalTitleSpinnerController({ + setTitle: (label) => this.state.terminal.setTitle(label), + staticTitle: () => { + const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; + return trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; + }, + isBusy: () => + this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting, + streamingPhase: () => this.state.appState.streamingPhase, + }); this.buildLayout(); } @@ -558,6 +573,7 @@ export class KimiTUI { return; } const shouldReplayHistory = await this.initMainTui(); + this.refreshTerminalMouseTracking(); this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { @@ -654,6 +670,7 @@ export class KimiTUI { this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); + this.refreshTerminalMouseTracking(); } private startClipboardImageHintController(): void { @@ -857,6 +874,7 @@ export class KimiTUI { this.tasksBrowserController.close(); this.btwPanelController.clear(); this.stopActivitySpinner(); + this.titleSpinner.dispose(); this.streamingUI.disposeActiveCompactionBlock(); this.streamingUI.resetToolUi(); this.disposeTranscriptChildren(); @@ -976,6 +994,7 @@ export class KimiTUI { private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.suspendTerminalMouseTracking(); this.clipboardImageHintController?.stop(); this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); @@ -1561,6 +1580,7 @@ export class KimiTUI { if (busyChanged) { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); + this.titleSpinner.sync(); } if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); @@ -1740,9 +1760,7 @@ export class KimiTUI { } updateTerminalTitle(): void { - const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; - const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; - this.state.terminal.setTitle(label); + this.titleSpinner.sync(); } resetSessionRuntime(): void { @@ -2751,6 +2769,20 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } + suspendTerminalMouseTracking(): void { + this.terminalMouseTrackingDispose?.(); + this.terminalMouseTrackingDispose = undefined; + } + + refreshTerminalMouseTracking(): void { + this.suspendTerminalMouseTracking(); + if (this.isShuttingDown) return; + if (!isExperimentalFlagEnabled('terminal_mouse_input')) return; + if (!this.state.ui.children.includes(this.state.editorContainer)) return; + if (!this.state.editorContainer.children.includes(this.state.editor)) return; + this.terminalMouseTrackingDispose = installEditorMouseTracking(this.state); + } + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; const palette = getBuiltInPalette(resolved); @@ -2827,6 +2859,8 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.suspendTerminalMouseTracking(); + this.state.editor.clearSelection(); this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); @@ -2837,6 +2871,7 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + this.refreshTerminalMouseTracking(); // 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. diff --git a/apps/kimi-code/src/tui/theme/detect.ts b/apps/kimi-code/src/tui/theme/detect.ts index 0f4d409532..63eb39300d 100644 --- a/apps/kimi-code/src/tui/theme/detect.ts +++ b/apps/kimi-code/src/tui/theme/detect.ts @@ -113,7 +113,8 @@ export function parseColorFgBg(value: string | undefined): ResolvedTheme | null const parts = value.split(";"); const bgRaw = parts.at(-1); if (bgRaw === undefined) return null; - const bg = parseInt(bgRaw, 10); + if (!/^\d+$/.test(bgRaw)) return null; + const bg = Number.parseInt(bgRaw, 10); if (!Number.isInteger(bg)) return null; // ANSI 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 8=bright black. const darkBgs = new Set([0, 1, 2, 3, 4, 5, 6, 8]); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 4f3c7d1176..1cc4f701ba 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, UpgradePreferences } from './config'; +import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -52,6 +52,8 @@ export interface AppState { disablePasteBurst?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; + /** Footer status line customization from tui.toml; absent means the default layout. */ + statusLine?: StatusLineConfig; availableModels: Record; availableProviders: Record; sessionTitle: string | null; diff --git a/apps/kimi-code/src/tui/utils/editor-mouse.ts b/apps/kimi-code/src/tui/utils/editor-mouse.ts new file mode 100644 index 0000000000..f738319543 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/editor-mouse.ts @@ -0,0 +1,210 @@ +import type { EditorPosition } from '@moonshot-ai/pi-tui'; + +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/tui-state'; + +interface EditorMouseTarget { + readonly row: number; + readonly col: number; +} + +type EditorMouseState = Pick; +type TerminalMouseTrackingState = Pick; + +export type TerminalMouseEventType = 'left-down' | 'left-drag' | 'left-up' | 'other'; + +export interface TerminalMouseEvent { + readonly type: TerminalMouseEventType; + readonly button: number; + readonly col: number; + readonly row: number; + readonly final: 'M' | 'm'; +} + +// oxlint-disable-next-line no-control-regex -- ESC is required for SGR mouse input. +const SGR_MOUSE_EVENT = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/g; +const MOUSE_MOTION_BIT = 32; +const MOUSE_WHEEL_BIT = 64; +const MOUSE_BUTTON_MASK = 3; +const DRAG_UPDATE_INTERVAL_MS = 16; + +function classifyMouseEvent(button: number, final: 'M' | 'm'): TerminalMouseEventType { + if ((button & MOUSE_WHEEL_BIT) !== 0) return 'other'; + const buttonId = button & MOUSE_BUTTON_MASK; + const moving = (button & MOUSE_MOTION_BIT) !== 0; + if (final === 'm') return buttonId === 0 || buttonId === 3 ? 'left-up' : 'other'; + if (buttonId === 3 && !moving) return 'left-up'; + if (buttonId !== 0) return 'other'; + return moving ? 'left-drag' : 'left-down'; +} + +export function parseSgrMouseEvent(data: string): TerminalMouseEvent | undefined { + SGR_MOUSE_EVENT.lastIndex = 0; + const match = SGR_MOUSE_EVENT.exec(data); + if (match === null || match.index !== 0 || match[0].length !== data.length) return undefined; + + const button = Number(match[1]); + const col = Number(match[2]); + const row = Number(match[3]); + const final = match[4] as 'M' | 'm'; + if (!Number.isInteger(button) || !Number.isInteger(col) || !Number.isInteger(row)) return undefined; + if (col < 1 || row < 1) return undefined; + return { type: classifyMouseEvent(button, final), button, col, row, final }; +} + +export function installTerminalMouseTracking( + state: TerminalMouseTrackingState, + onMouseEvent: (event: TerminalMouseEvent) => void, +): () => void { + const disposeInputListener = state.ui.addInputListener((data) => { + let remaining = ''; + let lastIndex = 0; + let matched = false; + SGR_MOUSE_EVENT.lastIndex = 0; + + for (const match of data.matchAll(SGR_MOUSE_EVENT)) { + matched = true; + remaining += data.slice(lastIndex, match.index); + lastIndex = match.index + match[0].length; + const event = parseSgrMouseEvent(match[0]); + if (event !== undefined) onMouseEvent(event); + } + + if (!matched) return undefined; + remaining += data.slice(lastIndex); + return remaining.length === 0 ? { consume: true } : { data: remaining }; + }); + state.terminal.write(ENABLE_TERMINAL_MOUSE_REPORTING); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_MOUSE_REPORTING); + }; +} + +export function installEditorMouseTracking(state: EditorMouseState): () => void { + let dragActive = false; + let lastAppliedPosition: EditorPosition | undefined; + let pendingPosition: EditorPosition | undefined; + let dragUpdateTimer: ReturnType | undefined; + + const samePosition = (a: EditorPosition | undefined, b: EditorPosition): boolean => + a?.line === b.line && a.col === b.col; + + const applyDragPosition = (position: EditorPosition): void => { + if (samePosition(lastAppliedPosition, position)) return; + lastAppliedPosition = position; + state.editor.updateSelection(position); + }; + + const clearDragTimer = (): void => { + if (dragUpdateTimer === undefined) return; + clearTimeout(dragUpdateTimer); + dragUpdateTimer = undefined; + }; + + const flushPendingDrag = (): void => { + dragUpdateTimer = undefined; + const position = pendingPosition; + pendingPosition = undefined; + if (!dragActive || position === undefined) return; + applyDragPosition(position); + dragUpdateTimer = setTimeout(flushPendingDrag, DRAG_UPDATE_INTERVAL_MS); + }; + + const queueDragPosition = (position: EditorPosition): void => { + if (samePosition(lastAppliedPosition, position) || samePosition(pendingPosition, position)) return; + if (dragUpdateTimer === undefined) { + applyDragPosition(position); + dragUpdateTimer = setTimeout(flushPendingDrag, DRAG_UPDATE_INTERVAL_MS); + return; + } + pendingPosition = position; + }; + + const disposeTracking = installTerminalMouseTracking(state, (event) => { + if (event.type === 'left-down') { + const position = resolveEditorPosition(state, event, false); + if (position === undefined) return; + clearDragTimer(); + pendingPosition = undefined; + dragActive = true; + lastAppliedPosition = position; + state.editor.beginSelection(position); + return; + } + + if (!dragActive) return; + if (event.type === 'left-drag') { + const position = resolveEditorPosition(state, event, true); + if (position !== undefined) queueDragPosition(position); + return; + } + + if (event.type === 'left-up') { + clearDragTimer(); + const position = resolveEditorPosition(state, event, true) ?? pendingPosition; + pendingPosition = undefined; + if (position !== undefined) applyDragPosition(position); + state.editor.finishSelection(); + dragActive = false; + lastAppliedPosition = undefined; + } + }); + + return () => { + clearDragTimer(); + pendingPosition = undefined; + dragActive = false; + lastAppliedPosition = undefined; + disposeTracking(); + }; +} + +function resolveEditorPosition( + state: EditorMouseState, + event: TerminalMouseEvent, + clamp: boolean, +): EditorPosition | undefined { + const target = resolveEditorMouseTarget(state, event, clamp); + if (target === undefined) return undefined; + return state.editor.positionAtRenderedCell(target.row, target.col, clamp); +} + +export function resolveEditorMouseTarget( + state: EditorMouseState, + event: Pick, + clamp: boolean, +): EditorMouseTarget | undefined { + if (!state.ui.children.includes(state.editorContainer)) return undefined; + if (!state.editorContainer.children.includes(state.editor)) return undefined; + + const { columns: terminalWidth, rows: terminalRows } = state.terminal; + if (terminalWidth < CHROME_GUTTER * 2 + 1 || terminalRows < 1) return undefined; + if (!clamp && (event.col > terminalWidth || event.row > terminalRows)) return undefined; + const layout = state.ui.getRenderedChildLayout(state.editorContainer); + if (layout === undefined || layout.width !== terminalWidth) return undefined; + + const viewportTop = state.ui.getRenderedViewportTop(); + const screenRow = Math.max(0, Math.min(terminalRows - 1, event.row - 1)); + const logicalRow = viewportTop + screenRow; + const editorWidth = Math.max(1, terminalWidth - CHROME_GUTTER * 2); + if (editorWidth < 3) return undefined; + const localRow = logicalRow - layout.startRow; + const localCol = event.col - CHROME_GUTTER - 1; + + if (!clamp) { + if (logicalRow < layout.startRow || logicalRow >= layout.endRow) return undefined; + if (localCol < 1 || localCol >= editorWidth - 1) return undefined; + return { row: localRow, col: localCol }; + } + + return { + row: Math.max(0, Math.min(layout.endRow - layout.startRow - 1, localRow)), + col: Math.max(1, Math.min(editorWidth - 2, localCol)), + }; +} diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts new file mode 100644 index 0000000000..6482a4ac7c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,185 @@ +/** + * User-provided status line command (`status_line.command` in tui.toml). + * + * The footer spawns the command with a JSON snapshot on stdin and renders the + * first stdout line. Runs are throttled and time-boxed; any failure (spawn + * error, nonzero exit, timeout) yields null so the caller falls back to the + * built-in layout. Mirrors Claude Code's statusLine contract at the seam: + * JSON in, first line out, 300ms ceiling. + */ + +import { spawn } from 'node:child_process'; + +export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; +export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; +export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; + +export interface StatusLinePayload { + model: string; + cwd: string; + gitBranch: string | null; + permissionMode: string; + planMode: boolean; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + sessionId: string; + version: string; +} + +export function runStatusLineCommand( + command: string, + payload: StatusLinePayload, + timeoutMs: number = STATUS_LINE_COMMAND_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + const isWin = process.platform === 'win32'; + let child; + try { + child = spawn(isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : 'sh', isWin ? ['/d', '/s', '/c', command] : ['-c', command], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, KIMI_CODE_STATUS_LINE: '1' }, + // Own process group on POSIX so a timeout can drop the whole tree, + // not just the shell wrapper. + detached: !isWin, + }); + } catch { + finish(null); + return; + } + + const killTree = (): void => { + if (child.pid === undefined) return; + if (isWin) { + try { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } catch { + // best effort + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } + }; + + const timer = setTimeout(() => { + killTree(); + finish(null); + }, timeoutMs); + timer.unref?.(); + + let stdout = ''; + child.stdout?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + if (stdout.includes('\n')) return; // first line is complete + stdout += chunk; + // Only the first line is ever used; stop accumulating past it (and cap + // a missing-newline stream) so a chatty command can't grow memory + // unboundedly before the timeout lands. + const cut = stdout.indexOf('\n'); + if (cut >= 0) { + stdout = stdout.slice(0, cut + 1); + } else if (stdout.length > STATUS_LINE_MAX_CAPTURE_BYTES) { + stdout = stdout.slice(0, STATUS_LINE_MAX_CAPTURE_BYTES); + } + }); + child.on('error', () => { + clearTimeout(timer); + finish(null); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + finish(null); + return; + } + const firstLine = (stdout.split('\n')[0] ?? '').trimEnd(); + finish(firstLine.length > 0 ? firstLine : null); + }); + + child.stdin?.on('error', () => { + // The command closed stdin early (e.g. `true`); nothing more to send. + }); + child.stdin?.end(JSON.stringify(payload)); + }); +} + +/** + * Throttled cache around `runStatusLineCommand` for a sync render path: + * `current()` returns the last good line, and a refresh is kicked off in the + * background at most once per interval. `onUpdate` fires when a fresh line + * lands so the footer can repaint. + */ +export class StatusLineCommandRunner { + private lastRunAt = 0; + private cached: string | null = null; + private inFlight = false; + private pendingPayload: StatusLinePayload | null = null; + private trailingTimer: ReturnType | null = null; + + constructor( + readonly command: string, + private readonly onUpdate: () => void, + ) {} + + current(): string | null { + return this.cached; + } + + maybeRefresh(payload: StatusLinePayload): void { + const now = Date.now(); + if (this.inFlight || now - this.lastRunAt < STATUS_LINE_RERUN_INTERVAL_MS) { + // Don't drop the update: land it as soon as the current gap expires, + // so a final state change is never lost to throttling. + this.pendingPayload = payload; + this.scheduleTrailing(now); + return; + } + this.startRun(payload, now); + } + + dispose(): void { + if (this.trailingTimer !== null) { + clearTimeout(this.trailingTimer); + this.trailingTimer = null; + } + this.pendingPayload = null; + } + + private scheduleTrailing(now: number): void { + if (this.trailingTimer !== null) return; + const waitMs = Math.max(0, STATUS_LINE_RERUN_INTERVAL_MS - (now - this.lastRunAt)); + this.trailingTimer = setTimeout(() => { + this.trailingTimer = null; + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }, waitMs); + this.trailingTimer.unref?.(); + } + + private startRun(payload: StatusLinePayload, now: number): void { + this.inFlight = true; + this.lastRunAt = now; + void runStatusLineCommand(this.command, payload).then((line) => { + this.inFlight = false; + if (line !== null) { + this.cached = line; + this.onUpdate(); + } + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }); + } +} diff --git a/apps/kimi-code/src/utils/terminal-restore.ts b/apps/kimi-code/src/utils/terminal-restore.ts index 5a93f3821a..b9943eb57d 100644 --- a/apps/kimi-code/src/utils/terminal-restore.ts +++ b/apps/kimi-code/src/utils/terminal-restore.ts @@ -14,8 +14,11 @@ */ // 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'; +// keyboard protocol (`4;0m`), and defensively +// disable all common terminal mouse tracking modes. +const TERMINAL_RESTORE_SEQUENCE = + '\u001B[?25h\u001B[?2004l\u001B[4;0m' + + '\u001B[?1006l\u001B[?1003l\u001B[?1002l\u001B[?1000l'; export function restoreTerminalModes(): void { try { diff --git a/apps/kimi-code/test/cli/acp.test.ts b/apps/kimi-code/test/cli/acp.test.ts index 383d6c9b7a..6e44d48ef6 100644 --- a/apps/kimi-code/test/cli/acp.test.ts +++ b/apps/kimi-code/test/cli/acp.test.ts @@ -15,6 +15,14 @@ vi.mock('@moonshot-ai/acp-adapter', () => ({ runAcpServer: vi.fn(async () => undefined), })); +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createKimiHarness: vi.fn(() => ({})), + }; +}); + import { runAcpServer } from '@moonshot-ai/acp-adapter'; import { registerAcpCommand } from '#/cli/sub/acp'; diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 8d45c2aba0..852afe49e0 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -99,7 +99,7 @@ const mocks = vi.hoisted(() => { ); return; } - stderr.write(`kimi version ${version}\n`); + stderr.write(`kkm version ${version}\n`); stdout.write('• hello world\n\n'); stderr.write('To resume this session: kkm -r ses_prompt\n'); }, @@ -326,6 +326,23 @@ describe('runPrompt', () => { ).rejects.toThrow('close failed'); }); + it('reports telemetry shutdown failure without changing a successful result', async () => { + const stdout = writer(); + const stderr = writer(); + mocks.shutdownTelemetry.mockRejectedValueOnce( + new Error('telemetry unavailable'), + ); + + await expect( + runPrompt(opts(), '1.2.3-test', { stdout, stderr, process: fakeProcess() }), + ).resolves.toBeUndefined(); + + expect(stderr.text()).toContain( + 'Warning: telemetry shutdown failed: telemetry unavailable', + ); + expect(mocks.harnessClose).toHaveBeenCalledOnce(); + }); + it('ignores a cleanup rejection that lands after the timeout', async () => { vi.useFakeTimers(); try { @@ -1304,8 +1321,8 @@ describe('runPrompt', () => { // first write, ahead of any assistant output or the resume hint. expect(mocks.runV2Print).toHaveBeenCalled(); expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); - expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n'); - expect(stderr.text().startsWith('kimi version 1.2.3-test\n')).toBe(true); + expect(stderr.write).toHaveBeenNthCalledWith(1, 'kkm version 1.2.3-test\n'); + expect(stderr.text().startsWith('kkm version 1.2.3-test\n')).toBe(true); expect(stdout.text()).toBe('• hello world\n\n'); }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c8bf2ffd75..00d8da44dc 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => { loadTuiConfig: vi.fn(), detectTerminalTheme: vi.fn(), kimiHarnessConstructor: vi.fn(), + kimiHarnessV2Constructor: vi.fn(), harnessEnsureConfigFile: vi.fn(), harnessGetConfig: vi.fn(async () => ({ providers: {}, @@ -91,6 +92,21 @@ const mocks = vi.hoisted(() => { vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { const actual = await importOriginal(); + const makeHarnessStub = (args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; + return { + homeDir, + auth: { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, + close: mocks.harnessClose, + track: mocks.harnessTrack, + }; + }; return { ...actual, resolveKimiHome: mocks.resolveKimiHome, @@ -109,17 +125,11 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { mocks.createKimiDeviceId(homeDir); } mocks.kimiHarnessConstructor(...args); - return { - homeDir, - auth: { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }, - ensureConfigFile: mocks.harnessEnsureConfigFile, - getConfig: mocks.harnessGetConfig, - getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, - close: mocks.harnessClose, - track: mocks.harnessTrack, - }; + return makeHarnessStub(args); + }, + createKimiHarnessV2: (...args: unknown[]) => { + mocks.kimiHarnessV2Constructor(...args); + return makeHarnessStub(args); }, }; }); @@ -196,6 +206,70 @@ describe('runShell', () => { mocks.harnessCreatesDeviceIdOnConstruction = false; }); + const minimalCliOptions = { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }; + + function stubTuiStartup(): void { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + } + + function withEnv(patch: Record, fn: () => Promise): Promise { + const saved: Record = {}; + for (const key of Object.keys(patch)) { + saved[key] = process.env[key]; + const value = patch[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return fn().finally(() => { + for (const key of Object.keys(patch)) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + } + + it('builds the v2 harness when the master experimental flag is set', async () => { + stubTuiStartup(); + await withEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '1' }, async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }); + expect(mocks.kimiHarnessV2Constructor).toHaveBeenCalledTimes(1); + expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); + }); + + it('keeps the v1 harness when the master experimental flag is unset', async () => { + stubTuiStartup(); + await withEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: undefined }, async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }); + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledTimes(1); + expect(mocks.kimiHarnessV2Constructor).not.toHaveBeenCalled(); + }); + it('constructs KimiHarness and KimiTUI with startup input', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index a558b752fe..b6654a92f3 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -29,10 +29,16 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({ withTelemetryContext: vi.fn(), })); -vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ - createKimiDeviceId: mocks.createKimiDeviceId, - KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', -})); +vi.mock('@moonshot-ai/kimi-code-oauth', async (importOriginal) => { + // Spread the real module: the SDK's v2 client pulls agent-core-v2 into the + // import graph, which subclasses KimiOAuthToolkit from this package. + const actual = await importOriginal(); + return { + ...actual, + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', + }; +}); vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { const actual = await importOriginal(); @@ -111,3 +117,90 @@ describe('initializeServerTelemetry', () => { ); }); }); + +describe('telemetry shutdown deadline', () => { + it('gives each pipeline only the budget remaining from one deadline', async () => { + const { createTelemetryShutdownDeadline } = await import( + '#/cli/telemetry-shutdown' + ); + let nowMs = 1_000; + const observedBudgets: number[] = []; + const deadline = createTelemetryShutdownDeadline( + 3_000, + vi.fn(), + () => nowMs, + ); + + await deadline.run(async (remainingMs) => { + observedBudgets.push(remainingMs); + nowMs = 2_250; + }); + await deadline.run(async (remainingMs) => { + observedBudgets.push(remainingMs); + }); + + expect(observedBudgets).toEqual([3_000, 1_750]); + }); + + it('still invokes a later pipeline with no budget after the deadline expires', async () => { + const { createTelemetryShutdownDeadline } = await import( + '#/cli/telemetry-shutdown' + ); + let nowMs = 1_000; + const pipeline = vi.fn(async (_remainingMs: number) => {}); + const deadline = createTelemetryShutdownDeadline( + 3_000, + vi.fn(), + () => nowMs, + ); + nowMs = 4_001; + + await deadline.run(pipeline); + + expect(pipeline).toHaveBeenCalledWith(0); + }); + + it('continues to a later pipeline after an earlier one rejects', async () => { + const { createTelemetryShutdownDeadline } = await import( + '#/cli/telemetry-shutdown' + ); + const laterPipeline = vi.fn(async (_remainingMs: number) => {}); + const reportError = vi.fn(); + const deadline = createTelemetryShutdownDeadline( + 3_000, + reportError, + () => 1_000, + ); + + await expect( + deadline.run(async () => { + throw new Error('telemetry unavailable'); + }), + ).resolves.toBeUndefined(); + await deadline.run(laterPipeline); + + expect(laterPipeline).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'telemetry unavailable' }), + ); + }); + + it('continues to a later pipeline when the error reporter also throws', async () => { + const { createTelemetryShutdownDeadline } = await import( + '#/cli/telemetry-shutdown' + ); + const laterPipeline = vi.fn(async (_remainingMs: number) => {}); + const deadline = createTelemetryShutdownDeadline(3_000, () => { + throw new Error('stderr unavailable'); + }); + + await expect( + deadline.run(async () => { + throw new Error('telemetry unavailable'); + }), + ).resolves.toBeUndefined(); + await deadline.run(laterPipeline); + + expect(laterPipeline).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index e6a5e5ce3e..b2846f2155 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -262,11 +262,33 @@ describe('runV2Print', () => { }, }); // Version banner is first, then the rendered assistant output. - expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n'); + expect(stderr.write).toHaveBeenNthCalledWith(1, 'kkm version 1.2.3-test\n'); expect(stdout.text()).toContain('hello world'); expect(app.dispose).toHaveBeenCalled(); }); + it('preserves a successful print result when telemetry shutdown rejects', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, appServices } = makeFakeHarness(); + const telemetry = appServices.get(ITelemetryService) as { + shutdown: ReturnType; + }; + telemetry.shutdown.mockRejectedValue(new Error('telemetry unavailable')); + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await expect( + runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }), + ).resolves.toBeUndefined(); + + expect(telemetry.shutdown).toHaveBeenCalledOnce(); + expect(app.dispose).toHaveBeenCalledOnce(); + expect(stderr.text()).toContain( + 'Warning: telemetry shutdown failed: telemetry unavailable', + ); + }); + it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => { const stdout = writer(); const stderr = writer(); diff --git a/apps/kimi-code/test/cli/vis.test.ts b/apps/kimi-code/test/cli/vis.test.ts index 8d525c3372..d5b4966857 100644 --- a/apps/kimi-code/test/cli/vis.test.ts +++ b/apps/kimi-code/test/cli/vis.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { handleVis, type VisDeps } from '#/cli/sub/vis'; +import { handleVis, parseVisPort, type VisDeps } from '#/cli/sub/vis'; function makeDeps(over: Partial = {}): { deps: VisDeps; @@ -112,3 +112,13 @@ describe('handleVis', () => { expect(deps.waitForShutdown).not.toHaveBeenCalled(); }); }); + +describe('parseVisPort', () => { + it('rejects malformed port values', () => { + expect(parseVisPort(undefined)).toBeUndefined(); + expect(parseVisPort('8080')).toBe(8080); + expect(() => parseVisPort('123abc')).toThrow(/invalid --port/); + expect(() => parseVisPort('1.5')).toThrow(/invalid --port/); + expect(() => parseVisPort('99999')).toThrow(/invalid --port/); + }); +}); diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index cc56e6001d..305e37d665 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -515,11 +515,100 @@ describe('`kimi web` option threading', () => { }); }); +describe('`kimi web` foreground shutdown', () => { + it('passes no fresh budget to host telemetry after engine shutdown expires', async () => { + vi.resetModules(); + const startServer = vi.fn(); + const shutdownTelemetry = vi.fn(async () => {}); + const logger = { info: vi.fn(), error: vi.fn() }; + vi.doMock('@moonshot-ai/kap-server', async () => { + const actual = await vi.importActual( + '@moonshot-ai/kap-server', + ); + return { + ...actual, + createServerLogger: vi.fn(() => logger), + startServer, + }; + }); + vi.doMock('@moonshot-ai/kimi-telemetry', async () => { + const actual = await vi.importActual( + '@moonshot-ai/kimi-telemetry', + ); + return { ...actual, shutdownTelemetry, track: vi.fn() }; + }); + vi.doMock('#/cli/telemetry', () => ({ initializeServerTelemetry: vi.fn() })); + + let nowMs = 10_000; + const serverClose = vi.fn(async () => { + nowMs = 13_001; + throw new Error('engine telemetry unavailable'); + }); + startServer.mockResolvedValue({ + host: '127.0.0.1', + port: 58_627, + close: serverClose, + }); + + const previousListeners = new Set(process.listeners('SIGINT')); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const now = vi.spyOn(Date, 'now').mockImplementation(() => nowMs); + let signalHandler: (() => void) | undefined; + try { + const { startServerForeground } = await import('#/cli/sub/web/run'); + let markReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + void startServerForeground( + { + host: '127.0.0.1', + port: 0, + logLevel: 'silent', + debugEndpoints: false, + insecureNoTls: true, + allowRemoteShutdown: false, + allowRemoteTerminals: false, + dangerousBypassAuth: false, + allowedHosts: [], + }, + { onReady: () => markReady?.() }, + ); + await ready; + + signalHandler = process + .listeners('SIGINT') + .find((listener) => !previousListeners.has(listener)) as (() => void) | undefined; + signalHandler?.(); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + expect(serverClose).toHaveBeenCalledWith({ telemetryDeadlineMs: 13_000 }); + expect(shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 0 }); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ message: 'engine telemetry unavailable' }), + }), + 'telemetry shutdown error', + ); + } finally { + if (signalHandler !== undefined) process.removeListener('SIGINT', signalHandler); + now.mockRestore(); + exit.mockRestore(); + vi.doUnmock('@moonshot-ai/kap-server'); + vi.doUnmock('@moonshot-ai/kimi-telemetry'); + vi.doUnmock('#/cli/telemetry'); + vi.resetModules(); + } + }); +}); + describe('shared parsers stay strict', () => { it('rejects out-of-range --port', async () => { const { parsePort } = await import('#/cli/sub/web/shared'); expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('123abc', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('1.5', '--port', 58627)).toThrow(/invalid --port/); expect(parsePort(undefined, '--port', 58627)).toBe(58627); expect(parsePort('8080', '--port', 58627)).toBe(8080); }); @@ -542,6 +631,38 @@ describe('server web asset directory resolution', () => { const { resolveServerWebAssetsDir } = await import('#/cli/sub/web/run'); expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); }); + + it('returns the assets dir when it is built, dev mode or not', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-')); + try { + writeFileSync(join(dir, 'index.html'), ''); + expect(serverWebAssetsDir({}, dir)).toBe(dir); + expect(serverWebAssetsDir({ KIMI_CODE_DEV_SERVER: '1' }, dir)).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('requires built assets outside dev mode', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-')); + try { + expect(serverWebAssetsDir({}, dir)).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('tolerates missing assets in dev mode (API-only server)', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-')); + try { + expect(serverWebAssetsDir({ KIMI_CODE_DEV_SERVER: '1' }, dir)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); function makeLegacyKillDeps(overrides: Partial = {}): { diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index bc4c5894fc..8d2799086c 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -89,7 +89,6 @@ describe('built-in slash command registry', () => { expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); expect(values('/.')).toBeNull(); const homeCompletions = values('~/') ?? []; - expect(homeCompletions.length).toBeGreaterThan(0); expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 77f582a1b9..3382df2c89 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -82,6 +82,7 @@ auto_install = false expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(host.refreshTerminalMouseTracking).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(true); expect(host.state.appState.theme).toBe('light'); expect(host.state.appState.availableModels).toEqual({ @@ -167,6 +168,7 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), + refreshTerminalMouseTracking: vi.fn(), refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), @@ -176,6 +178,7 @@ function makeHost({ readonly getExperimentalFeatures: ReturnType; }; readonly refreshSlashCommandAutocomplete: ReturnType; + readonly refreshTerminalMouseTracking: ReturnType; readonly reloadCurrentSessionView: ReturnType; readonly showStatus: ReturnType; }; diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 614553bb42..dbdccf9bce 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -196,7 +196,10 @@ describe('resolveSlashCommandInput', () => { }); it('resolves skill commands and blocks them while busy', () => { - const skillCommandMap = new Map([['skill:review', 'review']]); + const skillCommandMap = new Map([ + ['skill:review', 'review'], + ['skill:alpha-plugin:review', 'alpha-plugin:review'], + ]); expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({ kind: 'skill', @@ -204,6 +207,12 @@ describe('resolveSlashCommandInput', () => { skillName: 'review', args: 'src/app.ts', }); + expect(resolve('/skill:alpha-plugin:review src/app.ts', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'skill:alpha-plugin:review', + skillName: 'alpha-plugin:review', + args: 'src/app.ts', + }); expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'skill:review', diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index 51c364c3bc..c2dcd47db5 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -103,6 +103,17 @@ describe('skill slash commands', () => { expect(built.commandMap.get('mcp-config')).toBe('mcp-config'); }); + it('preserves a qualified plugin name through the slash-command map', () => { + const built = buildSkillSlashCommands([ + skill('alpha-plugin:review', 'prompt', { source: 'extra' }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual([ + 'skill:alpha-plugin:review', + ]); + expect(built.commandMap.get('skill:alpha-plugin:review')).toBe('alpha-plugin:review'); + }); + it('keeps sub-skills slash-invocable', () => { const built = buildSkillSlashCommands([ skill('outer.inner', 'prompt', { diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts new file mode 100644 index 0000000000..a6be39adfb --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -0,0 +1,262 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { + runStatusLineCommand, + STATUS_LINE_MAX_CAPTURE_BYTES, + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; +import type { AppState } from '#/tui/types'; + +const baseState: AppState = { + version: '1.2.3', + workDir: '/tmp/project', + additionalDirs: [], + sessionId: 'ses-1', + sessionTitle: null, + model: 'kimi-k2', + permissionMode: 'manual', + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + planMode: false, + inputMode: 'prompt', + swarmMode: false, + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + mcpServersSummary: null, +}; + +const payload: StatusLinePayload = { + model: 'kimi-k2', + cwd: '/tmp/project', + gitBranch: 'main', + permissionMode: 'manual', + planMode: false, + contextUsage: 12, + contextTokens: 1024, + maxContextTokens: 8192, + sessionId: 'ses-1', + version: '1.2.3', +}; + +function plain(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('FooterComponent status_line items', () => { + it('renders only the chosen slots in the given order', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['cwd', 'model'], command: null }, + }; + const footer = new FooterComponent(state); + + const line1 = plain(footer.render(120)[0]!); + const cwdAt = line1.indexOf('/tmp/project'); + const modelAt = line1.indexOf('kimi-k2'); + expect(cwdAt).toBeGreaterThanOrEqual(0); + expect(modelAt).toBeGreaterThan(cwdAt); + expect(line1).not.toContain('goal'); + }); + + it('keeps the default layout when statusLine is unset', () => { + const footer = new FooterComponent({ ...baseState }); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('kimi-k2'); + expect(line1).toContain('/tmp/project'); + }); + + it('drops the rotating tips when tips is not in items', () => { + const withTips = plain(new FooterComponent(baseState).render(200)[0]!); + const state: AppState = { + ...baseState, + statusLine: { items: ['model', 'cwd'], command: null }, + }; + const withoutTips = plain(new FooterComponent(state).render(200)[0]!); + + expect(withoutTips.length).toBeLessThan(withTips.length); + expect(withoutTips.trimEnd()).toMatch(/kimi-k2 {2}\/tmp\/project$/); + }); + + it('honors the configured position of the tips slot', () => { + // The tip content itself rotates; locate it via a tips-only render. + const tipsOnly = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips'], command: null }, + }).render(200)[0]!, + ).trim(); + + const tipsFirst = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips', 'model'], command: null }, + }).render(200)[0]!, + ); + const tipsLast = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['model', 'tips'], command: null }, + }).render(200)[0]!, + ); + + expect(tipsOnly.length).toBeGreaterThan(0); + expect(tipsFirst.indexOf(tipsOnly)).toBeLessThan(tipsFirst.indexOf('kimi-k2')); + expect(tipsLast.indexOf('kimi-k2')).toBeLessThan(tipsLast.indexOf(tipsOnly)); + }); + + it('renders nothing on line 1 for an empty items list', () => { + const state: AppState = { + ...baseState, + statusLine: { items: [], command: null }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!).trim()).toBe(''); + }); +}); + +describe('runStatusLineCommand', () => { + it('passes the payload as JSON on stdin and returns the first stdout line', async () => { + const line = await runStatusLineCommand('cat', payload); + + expect(line).not.toBeNull(); + const parsed = JSON.parse(line!); + expect(parsed.model).toBe('kimi-k2'); + expect(parsed.gitBranch).toBe('main'); + expect(parsed.cwd).toBe('/tmp/project'); + }); + + it('returns null on a nonzero exit', async () => { + expect(await runStatusLineCommand('exit 3', payload)).toBeNull(); + }); + + it('returns null on empty output', async () => { + expect(await runStatusLineCommand('true', payload)).toBeNull(); + }); + + it('returns null when the command overruns the timeout', async () => { + expect(await runStatusLineCommand('sleep 2', payload, 100)).toBeNull(); + }); + + it('trims the line and ignores later lines', async () => { + const line = await runStatusLineCommand('printf "first\\nsecond\\n"', payload); + + expect(line).toBe('first'); + }); + + it('caps the captured output instead of accumulating an unending stream', async () => { + // 200 KB on a single line, then exit: only the capped prefix is kept. + const line = await runStatusLineCommand( + 'head -c 200000 /dev/zero | tr "\\0" "a"', + payload, + ); + + expect(line).not.toBeNull(); + expect(line!.length).toBeLessThanOrEqual(STATUS_LINE_MAX_CAPTURE_BYTES); + }); +}); + +describe('FooterComponent status_line command', () => { + it('swaps line 1 to the command output once it lands', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "my-custom-status"' }, + }; + const footer = new FooterComponent(state); + + // Before the first run completes the built-in layout is still shown. + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('my-custom-status'); + }); + + it('keeps the built-in layout when the command fails', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'exit 1' }, + }; + const footer = new FooterComponent(state); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + }); +}); + +describe('StatusLineCommandRunner', () => { + it('caches the last good line and coalesces refreshes in the same interval', async () => { + const runner = new StatusLineCommandRunner('printf "x"', () => {}); + + runner.maybeRefresh(payload); + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(runner.current()).toBe('x'); + }); + + it('runs a deferred refresh after the throttle interval instead of dropping it', async () => { + const dir = join(tmpdir(), `sl-trailing-${process.pid}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + try { + const counterFile = join(dir, 'count'); + const scriptFile = join(dir, 'count.sh'); + writeFileSync(counterFile, '0'); + writeFileSync( + scriptFile, + '#!/bin/sh\nn=$(cat "$1")\necho $((n+1)) > "$1"\nprintf "run-%s" "$n"\n', + ); + const runner = new StatusLineCommandRunner(`sh ${scriptFile} ${counterFile}`, () => {}); + + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 250)); + runner.maybeRefresh(payload); // throttled: must defer, not drop + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('1'); + + await new Promise((resolve) => setTimeout(resolve, 800)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('2'); + runner.dispose(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('recreates the runner when the command changes', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "aaa"' }, + }; + const footer = new FooterComponent(state); + footer.render(120); // kicks the first run + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(plain(footer.render(120)[0]!)).toContain('aaa'); + + footer.setState({ ...state, statusLine: { items: null, command: 'printf "bbb"' } }); + footer.render(120); // kicks the replacement run + await new Promise((resolve) => setTimeout(resolve, 450)); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('bbb'); + expect(line1).not.toContain('aaa'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 3f42874861..8d1529e965 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -59,7 +59,7 @@ describe('TabbedModelSelectorComponent', () => { it('renders an "All" + per-provider tab strip', () => { const out = strip(make().component.render(120).join('\n')); expect(out).toContain('All'); - expect(out).toContain('Kimi Code'); + expect(out).toContain('KKM'); expect(out).toContain('openai'); }); 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 cf7184b3bd..720cf72d3b 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 @@ -116,6 +116,41 @@ describe('CustomEditor onNonEscapeInput', () => { }); }); +describe('CustomEditor Ctrl+C selection copy', () => { + it('copies the selection instead of invoking the app-level Ctrl+C action', () => { + const editor = makeEditor(); + const onCopySelection = vi.fn(); + const onCtrlC = vi.fn(); + editor.onCopySelection = onCopySelection; + editor.onCtrlC = onCtrlC; + editor.setText('hello world'); + editor.beginSelection({ line: 0, col: 6 }); + editor.updateSelection({ line: 0, col: 11 }); + editor.finishSelection(); + + editor.handleInput('\u0003'); + + expect(onCopySelection).toHaveBeenCalledWith('world'); + expect(onCtrlC).not.toHaveBeenCalled(); + expect(editor.getText()).toBe('hello world'); + expect(editor.hasSelection()).toBe(true); + }); + + it('preserves the existing app-level Ctrl+C behavior without a selection', () => { + const editor = makeEditor(); + const onCopySelection = vi.fn(); + const onCtrlC = vi.fn(); + editor.onCopySelection = onCopySelection; + editor.onCtrlC = onCtrlC; + editor.setText('hello world'); + + editor.handleInput('\u0003'); + + expect(onCopySelection).not.toHaveBeenCalled(); + expect(onCtrlC).toHaveBeenCalledOnce(); + }); +}); + describe('CustomEditor slash argument completion refresh', () => { it('reopens /add-dir directory completions after tab completion and entering slash', async () => { const editor = makeEditor(); @@ -725,6 +760,75 @@ describe('CustomEditor bash mode via paste', () => { expect(editor.inputMode).toBe('bash'); expect(editor.getText()).toBe(''); }); + + it('enters bash mode when a folded large ! paste (>1000 chars) lands in an empty prompt', () => { + const editor = makeEditor(); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); + + // 1 + 1000 = 1001 chars → pi-tui folds into a [paste #N …] marker + const body = `printf ok #${'x'.repeat(1000 - 'printf ok #'.length)}`; + const pasted = `!${body}`; + expect(pasted.length).toBe(1001); + + editor.handleInput(`${PASTE_START}${pasted}${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(modes).toEqual(['bash']); + expect(editor.getText()).toBe(body); + expect(editor.getText()).not.toContain('[paste #'); + expect(editor.getText().startsWith('!')).toBe(false); + }); + + it('enters bash mode for a ! paste at the 1000-char fold boundary', () => { + const editor = makeEditor(); + const body = `printf ok #${'x'.repeat(999 - 'printf ok #'.length)}`; + const pasted = `!${body}`; + expect(pasted.length).toBe(1000); + + editor.handleInput(`${PASTE_START}${pasted}${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(body); + }); + + it('enters bash mode when a folded multiline ! paste (>10 lines) lands in an empty prompt', () => { + const editor = makeEditor(); + // 11 lines beginning with ! → folded by line count + const lines = ['!echo one', ...Array.from({ length: 10 }, (_, i) => `echo line${i}`)]; + expect(lines.length).toBe(11); + const pasted = lines.join('\n'); + + editor.handleInput(`${PASTE_START}${pasted}${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(['echo one', ...lines.slice(1)].join('\n')); + expect(editor.getText()).not.toContain('[paste #'); + }); + + it('enters bash mode for a ! paste at the 10-line fold boundary', () => { + const editor = makeEditor(); + const lines = ['!echo one', ...Array.from({ length: 9 }, (_, i) => `echo line${i}`)]; + expect(lines.length).toBe(10); + const pasted = lines.join('\n'); + + editor.handleInput(`${PASTE_START}${pasted}${PASTE_END}`); + + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(['echo one', ...lines.slice(1)].join('\n')); + }); + + it('does not enter bash mode for a large paste without a leading !', () => { + const editor = makeEditor(); + const pasted = `printf ok #${'x'.repeat(1001 - 'printf ok #'.length)}`; + expect(pasted.length).toBe(1001); + expect(pasted.startsWith('!')).toBe(false); + + editor.handleInput(`${PASTE_START}${pasted}${PASTE_END}`); + + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toMatch(/\[paste #1 \d+ chars\]/); + }); }); describe('CustomEditor bash mode file completion', () => { diff --git a/apps/kimi-code/test/tui/components/messages/context-panel.test.ts b/apps/kimi-code/test/tui/components/messages/context-panel.test.ts new file mode 100644 index 0000000000..6a7587e387 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/context-panel.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import type { ContextBreakdownData } from '@moonshot-ai/kimi-code-sdk'; + +import { buildContextReportLines } from '#/tui/components/messages/context-panel'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +const BREAKDOWN: ContextBreakdownData = { + contextTokens: 28300, + usedTokens: 28300, + maxContextTokens: 200000, + systemPrompt: 2400, + systemTools: 8000, + mcpTools: 555, + mcpServers: [{ name: 'MiniMax', tokens: 555 }], + memoryFiles: 5100, + memoryFileEntries: [ + { path: '/home/user/.kimi-code/AGENTS.md', tokens: 4200 }, + { path: '/repo/AGENTS.md', tokens: 900 }, + ], + skills: 2000, + skillEntries: [ + { name: 'code-review', source: 'user', tokens: 140 }, + { name: 'tdd', source: 'project', tokens: 60 }, + ], + messages: 10200, +}; + +describe('context panel report lines', () => { + it('renders the model, window usage, and per-category token estimates', () => { + const lines = buildContextReportLines({ + model: 'MiniMax-M2.7-highspeed', + breakdown: BREAKDOWN, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Context Usage'); + expect(output).toContain('MiniMax-M2.7-highspeed'); + expect(output).toContain('27.6k/195k tokens (14.1%)'); + expect(output).toContain('Estimated usage by category'); + expect(output).toContain('System prompt: 2.3k tokens (1.2%)'); + expect(output).toContain('System tools: 7.8k tokens (4.0%)'); + expect(output).toContain('MCP tools: 555 tokens (0.3%)'); + expect(output).toContain('Memory files: 5k tokens (2.5%)'); + expect(output).toContain('Skills: 2k tokens (1.0%)'); + expect(output).toContain('Messages: 10k tokens (5.1%)'); + expect(output).toContain('Free space: 168k tokens (85.9%)'); + }); + + it('renders per-server, per-file, and per-skill token detail', () => { + const lines = buildContextReportLines({ + model: 'k2', + breakdown: BREAKDOWN, + mcpServers: [ + { name: 'MiniMax', transport: 'stdio', status: 'connected', toolCount: 2 }, + { name: 'broken', transport: 'http', status: 'failed', toolCount: 0 }, + ], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('MCP tools: 555 tokens (0.3%) · 2 tools'); + expect(output).toContain('MCP servers · /mcp'); + expect(output).toContain('MiniMax (2 tools) ~555 tokens'); + // A server without exposed tools gets no token estimate. + expect(output).toContain('broken (0 tools)'); + expect(output).not.toContain('broken (0 tools) ~'); + expect(output).toContain('Memory files'); + expect(output).toContain('/home/user/.kimi-code/AGENTS.md ~4.1k tokens'); + expect(output).toContain('/repo/AGENTS.md ~900 tokens'); + expect(output).toContain('Skills · /skills'); + expect(output).toContain('code-review [user] ~140 tokens'); + expect(output).toContain('tdd [project] ~60 tokens'); + }); + + it('omits percentages and free space when the context window is unknown', () => { + const lines = buildContextReportLines({ + model: 'k2', + breakdown: { ...BREAKDOWN, maxContextTokens: 0 }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('27.6k tokens'); + expect(output).toContain('System prompt: 2.3k tokens'); + expect(output).not.toContain('(1.2%)'); + expect(output).toContain('Free space: unknown'); + }); + + it('keeps free space consistent with the categories before the first LLM round-trip', () => { + const overhead = + BREAKDOWN.systemPrompt + + BREAKDOWN.systemTools + + BREAKDOWN.mcpTools + + BREAKDOWN.memoryFiles + + BREAKDOWN.skills; + const lines = buildContextReportLines({ + model: 'k2', + breakdown: { ...BREAKDOWN, contextTokens: 0, usedTokens: overhead, messages: 0 }, + }).map(strip); + + const output = lines.join('\n'); + // The header and free space follow the estimated category sum (18.1k), + // not the not-yet-known LLM total (0) — "100% free" would contradict the + // non-zero category rows. + expect(output).toContain('17.6k/195k tokens (9.0%)'); + expect(output).toContain('Messages: 0 tokens (0.0%)'); + expect(output).toContain('Free space: 178k tokens (91.0%)'); + }); + + it('renders the error instead of the report when the breakdown fails', () => { + const lines = buildContextReportLines({ + model: 'k2', + error: 'session closed', + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Context Usage'); + expect(output).toContain('session closed'); + expect(output).not.toContain('Estimated usage by category'); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..664fda616f 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -63,6 +63,7 @@ auto_install = false editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }); }); @@ -87,6 +88,7 @@ command = " " editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }); }); @@ -119,6 +121,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }, filePath, ); @@ -129,6 +132,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }); }); @@ -141,6 +145,7 @@ command = " " editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, + statusLine: DEFAULT_TUI_CONFIG.statusLine, }, filePath, ); @@ -148,3 +153,92 @@ command = " " expect((await loadTuiConfig(filePath)).theme).toBe(theme); }); }); + +describe('TUI config status_line', () => { + it('defaults to null when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.statusLine).toEqual({ items: null, command: null }); + }); + + it('parses items and command', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "git", "cwd"] +command = "~/.kimi-code/statusline.sh" +`); + + expect(config.statusLine).toEqual({ + items: ['model', 'git', 'cwd'], + command: '~/.kimi-code/statusline.sh', + }); + }); + + it('skips unknown items with a warning instead of failing the whole file', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "wat", "git"] +`); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + }); + + it('routes unknown-item warnings through the provided callback instead of stderr', () => { + const warnings: string[] = []; + const config = parseTuiConfig( + ` +[status_line] +items = ["model", "wat", "git"] +`, + (message) => warnings.push(message), + ); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + expect(warnings).toEqual(['[tui.toml] ignoring unknown status_line item: wat']); + }); + + it('normalizes an empty command to null', () => { + const config = parseTuiConfig(` +[status_line] +command = " " +`); + + expect(config.statusLine?.command).toBeNull(); + }); + + it('documents status_line in the rendered template', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('[status_line]'); + expect(text).toContain('items'); + expect(text).toContain('command'); + }); +}); + +describe('TUI config status_line round-trip', () => { + it('preserves an active status_line across save and reload', async () => { + await saveTuiConfig( + { + ...DEFAULT_TUI_CONFIG, + statusLine: { items: ['model', 'git'], command: '~/.kimi-code/statusline.sh' }, + }, + filePath, + ); + + const reloaded = await loadTuiConfig(filePath); + expect(reloaded.statusLine).toEqual({ + items: ['model', 'git'], + command: '~/.kimi-code/statusline.sh', + }); + }); + + it('keeps the status_line section commented out when unset', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('# [status_line]'); + expect(text).toContain('# items ='); + expect(text).toContain('# command ='); + }); +}); diff --git a/apps/kimi-code/test/tui/constant/title-spinners.test.ts b/apps/kimi-code/test/tui/constant/title-spinners.test.ts new file mode 100644 index 0000000000..7b9f8ef1f3 --- /dev/null +++ b/apps/kimi-code/test/tui/constant/title-spinners.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_TITLE_SPINNER_ID, + OFF_TITLE_SPINNER_ID, + PHASE_TITLE_GLYPHS, + PHASE_TITLE_SPINNER_ID, + resolveTitleSpinnerId, + TITLE_SPINNER_STYLES, +} from '#/tui/constant/title-spinners'; + +describe('resolveTitleSpinnerId', () => { + it('returns a registered style id unchanged', () => { + for (const id of Object.keys(TITLE_SPINNER_STYLES)) { + expect(resolveTitleSpinnerId(id)).toBe(id); + } + }); + + it('normalizes case and surrounding whitespace', () => { + expect(resolveTitleSpinnerId(' MOON ')).toBe('moon'); + expect(resolveTitleSpinnerId('Braille')).toBe('braille'); + }); + + it('accepts the special off and phase ids', () => { + expect(resolveTitleSpinnerId('off')).toBe(OFF_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('PHASE')).toBe(PHASE_TITLE_SPINNER_ID); + }); + + it('falls back to the default for unknown or empty values', () => { + expect(resolveTitleSpinnerId(undefined)).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('')).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId(' ')).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('nope')).toBe(DEFAULT_TITLE_SPINNER_ID); + }); + + it('default id is a registered style', () => { + expect(TITLE_SPINNER_STYLES[DEFAULT_TITLE_SPINNER_ID]).toBeDefined(); + }); + + it('rejects inherited object keys instead of treating them as styles', () => { + expect(resolveTitleSpinnerId('constructor')).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('toString')).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('hasOwnProperty')).toBe(DEFAULT_TITLE_SPINNER_ID); + expect(resolveTitleSpinnerId('__proto__')).toBe(DEFAULT_TITLE_SPINNER_ID); + }); +}); + +describe('TITLE_SPINNER_STYLES', () => { + it('every style has at least two non-empty frames and a positive interval', () => { + for (const [id, style] of Object.entries(TITLE_SPINNER_STYLES)) { + expect(style.frames.length, id).toBeGreaterThanOrEqual(2); + for (const frame of style.frames) { + expect(frame.length, id).toBeGreaterThan(0); + } + expect(style.interval, id).toBeGreaterThan(0); + } + }); +}); + +describe('PHASE_TITLE_GLYPHS', () => { + it('every busy phase maps to a non-empty glyph', () => { + for (const phase of ['waiting', 'thinking', 'composing', 'shell']) { + expect(PHASE_TITLE_GLYPHS[phase]?.length, phase).toBeGreaterThan(0); + } + }); +}); 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 8f15e68426..7d1258646c 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -6,6 +6,13 @@ import { type EditorKeyboardHost, } from '#/tui/controllers/editor-keyboard'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; + +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => 'native'), +})); + +const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); interface Harness { readonly host: EditorKeyboardHost; @@ -83,6 +90,21 @@ function pressNonEscape(editor: Harness['editor']): void { (handler as () => void)(); } +describe('EditorKeyboardController selection copy', () => { + it('writes selected text to the clipboard without changing editor content', async () => { + copyTextToClipboardMock.mockRejectedValueOnce(new Error('clipboard unavailable')); + const { editor } = createHarness(); + const handler = editor['onCopySelection']; + if (handler === undefined) throw new Error('onCopySelection handler not installed'); + + (handler as unknown as (text: string) => void)('selected text'); + await Promise.resolve(); + + expect(copyTextToClipboardMock).toHaveBeenCalledWith('selected text'); + expect(editor['setText']).not.toHaveBeenCalled(); + }); +}); + describe('EditorKeyboardController double-Esc undo', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts new file mode 100644 index 0000000000..911826c712 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MAX_TERMINAL_TITLE_LENGTH } from '#/tui/constant/terminal'; +import { TITLE_SPINNER_STYLES } from '#/tui/constant/title-spinners'; +import { + TerminalTitleSpinnerController, + type TerminalTitleSpinnerHost, +} from '#/tui/controllers/terminal-title-spinner'; + +interface FakeHostState { + busy: boolean; + phase: string; + staticTitle: string; +} + +function createHost(state: FakeHostState): { host: TerminalTitleSpinnerHost; titles: string[] } { + const titles: string[] = []; + const host: TerminalTitleSpinnerHost = { + setTitle: vi.fn((label: string) => { + titles.push(label); + }), + staticTitle: () => state.staticTitle, + isBusy: () => state.busy, + streamingPhase: () => state.phase, + }; + return { host, titles }; +} + +describe('TerminalTitleSpinnerController', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes the static title and runs no timer when idle', () => { + const { host, titles } = createHost({ busy: false, phase: 'idle', staticTitle: 'My Session' }); + const controller = new TerminalTitleSpinnerController(host, 'moon'); + controller.sync(); + expect(titles).toEqual(['My Session']); + vi.advanceTimersByTime(2000); + expect(titles).toEqual(['My Session']); + }); + + it('animates the title with the selected style while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'Fix bug' }); + const frames = TITLE_SPINNER_STYLES['line']!.frames; + const interval = TITLE_SPINNER_STYLES['line']!.interval; + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`${frames[0]} Fix bug`); + vi.advanceTimersByTime(interval); + expect(titles[1]).toBe(`${frames[1]} Fix bug`); + controller.dispose(); + }); + + it('runs a single timer across repeated syncs while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'x' }); + const interval = TITLE_SPINNER_STYLES['line']!.interval; + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + controller.sync(); + controller.sync(); + const writesBefore = titles.length; + vi.advanceTimersByTime(interval); + // Exactly one timer tick fires, so exactly one extra write happens. + expect(titles.length).toBe(writesBefore + 1); + controller.dispose(); + }); + + it('off keeps the static title even while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'S' }); + const controller = new TerminalTitleSpinnerController(host, 'off'); + controller.sync(); + vi.advanceTimersByTime(2000); + expect(titles).toEqual(['S']); + }); + + it('phase maps the streaming phase to a glyph without any timer', () => { + const state: FakeHostState = { busy: true, phase: 'thinking', staticTitle: 'S' }; + const { host, titles } = createHost(state); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toBe('✦ S'); + state.phase = 'shell'; + controller.sync(); + expect(titles[1]).toBe('▸ S'); + vi.advanceTimersByTime(5000); + expect(titles).toHaveLength(2); + }); + + it('dispose stops the timer and restores the static title', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'Done' }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + controller.dispose(); + expect(titles.at(-1)).toBe('Done'); + const countAfterDispose = titles.length; + vi.advanceTimersByTime(2000); + expect(titles).toHaveLength(countAfterDispose); + }); + + it('unknown style falls back to the default spinner', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'T' }); + const controller = new TerminalTitleSpinnerController(host, 'definitely-not-a-style'); + const firstFrame = TITLE_SPINNER_STYLES['moon']!.frames[0]; + controller.sync(); + expect(titles[0]).toBe(`${firstFrame} T`); + controller.dispose(); + }); + + it('keeps the composed title within the terminal-title cap', () => { + const longTitle = 'x'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toHaveLength(MAX_TERMINAL_TITLE_LENGTH); + expect(titles[0]).toBe(`| ${'x'.repeat(MAX_TERMINAL_TITLE_LENGTH - 2)}`); + controller.dispose(); + }); + + it('does not split a surrogate pair at the truncation boundary', () => { + // 29 ASCII chars plus one emoji = 31 UTF-16 units; with the `| ` prefix + // the composed title is 33 units, so a naive slice would keep a lone high + // surrogate at the cap. + const longTitle = `${'x'.repeat(29)}\u{1F311}`; + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`| ${'x'.repeat(29)}`); + controller.dispose(); + }); + + it('drops a multi-code-point grapheme whole instead of splitting it', () => { + // 27 ASCII chars plus a flag emoji (two regional indicators, 4 UTF-16 + // units); with the `| ` prefix the composed title is 33 units, so the + // flag straddles the 32-unit cap and must be dropped as one grapheme. + const longTitle = `${'a'.repeat(27)}\u{1F1E8}\u{1F1F3}`; + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`| ${'a'.repeat(27)}`); + controller.dispose(); + }); + + it('keeps the phase title within the terminal-title cap', () => { + const longTitle = 'y'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toHaveLength(MAX_TERMINAL_TITLE_LENGTH); + expect(titles[0]).toBe(`✦ ${'y'.repeat(MAX_TERMINAL_TITLE_LENGTH - 2)}`); + }); + + it('ignores inherited phase keys instead of stringifying them', () => { + const { host, titles } = createHost({ busy: true, phase: 'constructor', staticTitle: 'S' }); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toBe('S'); + }); +}); diff --git a/apps/kimi-code/test/tui/editor-mouse.test.ts b/apps/kimi-code/test/tui/editor-mouse.test.ts new file mode 100644 index 0000000000..143370b4fb --- /dev/null +++ b/apps/kimi-code/test/tui/editor-mouse.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TUIState } from '#/tui/kimi-tui'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; +import { + installEditorMouseTracking, + installTerminalMouseTracking, + parseSgrMouseEvent, + resolveEditorMouseTarget, +} from '#/tui/utils/editor-mouse'; + +type InputListener = Parameters[0]; + +function trackingState() { + const listeners: InputListener[] = []; + const removeInputListener = vi.fn(); + const terminal = { + columns: 40, + rows: 20, + write: vi.fn(), + }; + const ui = { + addInputListener: vi.fn((listener: InputListener) => { + listeners.push(listener); + return removeInputListener; + }), + getRenderedViewportTop: vi.fn(() => 0), + }; + return { listeners, removeInputListener, terminal, ui }; +} + +describe('terminal mouse input', () => { + it('classifies SGR left-button events', () => { + expect(parseSgrMouseEvent('\u001B[<0;10;5M')).toMatchObject({ + type: 'left-down', + col: 10, + row: 5, + }); + expect(parseSgrMouseEvent('\u001B[<32;11;6M')).toMatchObject({ + type: 'left-drag', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('\u001B[<0;11;6m')).toMatchObject({ + type: 'left-up', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('\u001B[<3;11;6M')).toMatchObject({ + type: 'left-up', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('x')).toBeUndefined(); + }); + + it('consumes mouse sequences while preserving ordinary input', () => { + const state = trackingState(); + const events: string[] = []; + const dispose = installTerminalMouseTracking( + state as unknown as Pick, + (event) => events.push(event.type), + ); + + expect(state.terminal.write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + expect(state.listeners).toHaveLength(1); + expect(state.listeners[0]?.('\u001B[<0;10;5M')).toEqual({ consume: true }); + expect(state.listeners[0]?.('a\u001B[<32;11;6Mb')).toEqual({ data: 'ab' }); + expect(events).toEqual(['left-down', 'left-drag']); + + dispose(); + expect(state.removeInputListener).toHaveBeenCalledOnce(); + expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + }); + + it('drives editor selection from press, drag, and release', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { + children: [] as unknown[], + render: vi.fn(() => ['editor-top', 'editor-line', 'editor-bottom']), + }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn((row: number, col: number) => ({ line: row - 1, col })), + }; + editorContainer.children.push(editor); + const transcript = { render: vi.fn(() => ['transcript']) }; + const ui = { + ...state.ui, + children: [transcript, editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + const dispose = installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + const listener = state.listeners[0]; + listener?.('\u001B[<0;2;3M'); + expect(editor.beginSelection).not.toHaveBeenCalled(); + + listener?.('\u001B[<0;3;3M'); + listener?.('\u001B[<32;5;3M'); + listener?.('\u001B[<0;5;3m'); + + expect(editor.beginSelection).toHaveBeenCalledOnce(); + expect(editor.updateSelection).toHaveBeenCalledOnce(); + expect(editor.finishSelection).toHaveBeenCalledOnce(); + expect(transcript.render).not.toHaveBeenCalled(); + expect(editorContainer.render).not.toHaveBeenCalled(); + + dispose(); + }); + + it('maps a top-aligned short frame without vertical or prompt-column offset', () => { + const state = trackingState(); + state.terminal.rows = 6; + const editor = {}; + const editorContainer = { children: [editor] }; + const ui = { + ...state.ui, + children: [editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + const target = resolveEditorMouseTarget( + { + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick, + { row: 3, col: 6 }, + false, + ); + + // Screen row 3 is the editor's first content row. Column 6 is the first + // text cell after outer gutter + border + `> ` prompt padding. + expect(target).toEqual({ row: 1, col: 4 }); + }); + + it('coalesces high-frequency drag events and resets cleanly between drags', () => { + vi.useFakeTimers(); + try { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { children: [] as unknown[] }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn((row: number, col: number) => ({ line: row - 1, col })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + children: [editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 0, + endRow: 3, + totalRows: 3, + width: 40, + })), + }; + + const dispose = installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + const listener = state.listeners[0]!; + + for (let drag = 0; drag < 3; drag++) { + listener('\u001B[<0;3;2M'); + for (let col = 4; col <= 30; col++) { + listener(`\u001B[<32;${col};2M`); + } + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 1); + vi.advanceTimersByTime(16); + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 2); + listener('\u001B[<0;30;2m'); + expect(editor.finishSelection).toHaveBeenCalledTimes(drag + 1); + vi.runOnlyPendingTimers(); + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 2); + } + + dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('uses the actual preserved viewport after the editor shrinks', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { children: [] as unknown[] }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn(() => ({ line: 0, col: 0 })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + children: [editorContainer], + getRenderedViewportTop: vi.fn(() => 2), + getRenderedChildLayout: vi.fn(() => ({ + startRow: 2, + endRow: 5, + totalRows: 5, + width: 40, + })), + }; + + installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + state.listeners[0]?.('\u001B[<0;3;2M'); + + expect(editor.positionAtRenderedCell).toHaveBeenCalledWith(1, 1, false); + expect(editor.beginSelection).toHaveBeenCalledOnce(); + }); + + it('ignores drags that did not start in the editor', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { + children: [] as unknown[], + render: vi.fn(() => ['editor-top', 'editor-line', 'editor-bottom']), + }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn(() => ({ line: 0, col: 0 })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + children: [{ render: vi.fn(() => ['transcript']) }, editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + state.listeners[0]?.('\u001B[<32;5;3M'); + state.listeners[0]?.('\u001B[<0;5;3m'); + + expect(editor.beginSelection).not.toHaveBeenCalled(); + expect(editor.updateSelection).not.toHaveBeenCalled(); + expect(editor.finishSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f9f46185e7..b6c86004a6 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -150,6 +150,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', @@ -310,9 +311,12 @@ async function makeDriver( harness: ReturnType; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + const tui = new KimiTUI(harness as never, makeStartupInput()); + liveDrivers.push(tui); + const driver = tui as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setTitle').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); await driver.init(); return { driver, session, harness }; @@ -399,6 +403,7 @@ function countOccurrences(haystack: string, needle: string): number { } const tempDirs: string[] = []; +const liveDrivers: KimiTUI[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; const originalPluginMarketplaceUrl = process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL']; const originalVisual = process.env['VISUAL']; @@ -419,6 +424,11 @@ async function makeExportedSessionZip(content = 'session zip'): Promise } afterEach(async () => { + // Stop title-spinner intervals for drivers a test left busy, so timers do + // not outlive the test and keep writing OSC titles. + for (const tui of liveDrivers.splice(0)) { + tui.titleSpinner.dispose(); + } resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index ff8a8b40cf..27478487bd 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; import { readBannerDisplayState } from '#/tui/banner/state'; import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; @@ -16,6 +17,10 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { quoteShellArg } from '#/utils/shell-quote'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -50,6 +55,11 @@ interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } +interface MouseTrackingDriver extends StartupDriver { + refreshTerminalMouseTracking(): void; + suspendTerminalMouseTracking(): void; +} + interface MigrateExitDriver extends StartupDriver { start(): Promise; onExit?: (code?: number) => Promise; @@ -95,6 +105,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, ...tuiConfig, }, version: '0.0.0-test', @@ -1043,6 +1054,38 @@ describe('KimiTUI startup', () => { expect(stop).not.toHaveBeenCalled(); }); + it('tracks terminal mouse input only while the main editor is mounted', () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as MouseTrackingDriver; + const { write, addInputListener, removeInputListener } = captureInputListeners(driver); + + try { + setExperimentalFeatures([{ id: 'terminal_mouse_input', enabled: true }]); + driver.state.editorContainer.clear(); + driver.state.editorContainer.addChild(driver.state.editor); + + driver.refreshTerminalMouseTracking(); + + expect(addInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + + driver.suspendTerminalMouseTracking(); + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + + driver.state.ui.clear(); + driver.refreshTerminalMouseTracking(); + expect(addInputListener).toHaveBeenCalledOnce(); + + driver.state.ui.addChild(driver.state.editorContainer); + driver.state.editorContainer.clear(); + driver.refreshTerminalMouseTracking(); + expect(addInputListener).toHaveBeenCalledOnce(); + } finally { + setExperimentalFeatures([]); + } + }); + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 20d825ce04..d944a485f8 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -65,6 +65,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 63633a9657..ce3914d7ed 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -37,6 +37,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index dacf75f5f9..00932bc09b 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,5 +1,9 @@ -import type { Terminal } from '@moonshot-ai/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { Component, ProcessTerminal, Terminal, TUI } from '@moonshot-ai/pi-tui'; +import type { + BackgroundTaskInfo, + BackgroundTaskStatus, + Session, +} from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import { @@ -7,6 +11,11 @@ import { type TasksBrowserProps, type TasksFilter, } from '@/tui/components/dialogs/tasks-browser'; +import { + TasksBrowserController, + type TasksBrowserHost, + type TasksBrowserState, +} from '@/tui/controllers/tasks-browser'; import { darkColors } from '@/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -490,3 +499,67 @@ describe('TasksBrowserApp — setProps', () => { } }); }); + +describe('TasksBrowserController — terminal mouse lifecycle', () => { + it('suspends tracking during the full-screen takeover and restores it on close', async () => { + const originalChildren = [ + { render: () => ['transcript'], invalidate: () => {} }, + { render: () => ['editor'], invalidate: () => {} }, + ] as unknown as Component[]; + const children = [...originalChildren]; + const events: string[] = []; + const ui = { + children, + clear: vi.fn(() => { + events.push('clear'); + children.splice(0); + }), + addChild: vi.fn((component: Component) => { + events.push('add'); + children.push(component); + }), + setFocus: vi.fn(), + requestRender: vi.fn(), + } as unknown as TUI; + const state = { + tasksBrowser: undefined as TasksBrowserState | undefined, + theme: darkColors as unknown as TasksBrowserHost['state']['theme'], + terminal: fakeTerminal(30) as unknown as ProcessTerminal, + ui, + editor: {} as TasksBrowserHost['state']['editor'], + }; + const suspendTerminalMouseTracking = vi.fn(() => { + events.push('suspend'); + }); + const refreshTerminalMouseTracking = vi.fn(() => { + events.push('refresh'); + }); + const host: TasksBrowserHost = { + state, + backgroundTasks: new Map(), + session: { + listBackgroundTasks: vi.fn(async () => []), + } as unknown as Session, + showError: vi.fn(), + setTasksBrowser(value) { + state.tasksBrowser = value; + }, + suspendTerminalMouseTracking, + refreshTerminalMouseTracking, + }; + const controller = new TasksBrowserController(host); + + await controller.show(); + + expect(suspendTerminalMouseTracking).toHaveBeenCalledOnce(); + expect(events.indexOf('suspend')).toBeLessThan(events.indexOf('clear')); + expect(children).toHaveLength(1); + expect(state.tasksBrowser).toBeDefined(); + + controller.close(); + + expect(refreshTerminalMouseTracking).toHaveBeenCalledOnce(); + expect(children).toEqual(originalChildren); + expect(events.lastIndexOf('refresh')).toBeGreaterThan(events.lastIndexOf('add')); + }); +}); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 3159f80256..7b1ec997b3 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -15,6 +15,7 @@ import { handleTerminalThemeInput, installTerminalThemeTracking, } from "#/tui/utils/terminal-theme"; +import { parseColorFgBg } from "#/tui/theme/detect"; type InputListener = Parameters[0]; const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; @@ -173,3 +174,11 @@ describe('ColorPalette warning token', () => { expect(getBuiltInPalette('light')).toBe(lightColors); }); }); + +describe("parseColorFgBg", () => { + it("rejects malformed background color indexes", () => { + expect(parseColorFgBg("0;9")).toBe("light"); + expect(parseColorFgBg("0;9abc")).toBeNull(); + expect(parseColorFgBg("0;1.5")).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/utils/terminal-restore.test.ts b/apps/kimi-code/test/utils/terminal-restore.test.ts new file mode 100644 index 0000000000..d40887bd02 --- /dev/null +++ b/apps/kimi-code/test/utils/terminal-restore.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { restoreTerminalModes } from '#/utils/terminal-restore'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('restoreTerminalModes', () => { + it('disables terminal mouse reporting during emergency restoration', () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + restoreTerminalModes(); + + const output = write.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(output).toContain('\u001B[?1000l'); + expect(output).toContain('\u001B[?1002l'); + expect(output).toContain('\u001B[?1003l'); + expect(output).toContain('\u001B[?1006l'); + }); +}); diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 07ed0703b0..bbb8c562f6 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -8,23 +8,28 @@ * / State tabs), the chat column, and the right dock (`RightPanel`) merging * the transcript audit and the agent inspector under Audit / Agent tabs; * the `models` view is the full-width model catalog; the `services` view is - * the full-width app-scope Service reflection (`AppServicesView`). + * the full-width app-scope Service reflection (`AppServicesView`); the + * `bash` view is the full-width `IBashParserService` playground + * (`BashParserView`); the `search` view is the full-width global message + * search (`SearchView`) whose hits navigate back into the chat timeline. */ -import { useEffect, useState } from 'react'; - import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { useEffect, useState } from 'react'; import type { AuditTrail } from './audit/trail'; import { AppServicesView } from './components/AppServicesView'; -import { ChatView } from './components/ChatView'; +import { BashParserView } from './components/BashParserView'; +import { ChatView, type ChatJump } from './components/ChatView'; import { ModelCatalogView } from './components/ModelCatalogView'; import { NavRail, type AppView } from './components/NavRail'; import { RightPanel } from './components/RightPanel'; +import { SearchView } from './components/SearchView'; import { ServerSwitcher } from './components/ServerSwitcher'; import { SessionPane } from './components/SessionPane'; import { Sidebar } from './components/Sidebar'; import { useConnection } from './connection'; +import type { SearchHit } from './search/api'; import { errorMessage } from './ui'; export function App() { @@ -36,6 +41,8 @@ export function App() { const [resumeError, setResumeError] = useState(null); /** Audit trail of the chat view's transcript channel, rendered in the right dock. */ const [trail, setTrail] = useState(null); + /** Pending chat navigation requested from another view (search result click). */ + const [jump, setJump] = useState(null); // Resume (materialize) the session on the server when it is selected, so // session / agent scoped Services become reachable. @@ -63,8 +70,23 @@ export function App() { useEffect(() => { setSessionId(null); setAgentId('main'); + setJump(null); }, [baseUrl]); + // A search hit opens the chat view at its session / agent / turn / step. + // Title hits belong to the session (agent '') and carry no turn: switch + // over without a scroll target. + const openSearchHit = (hit: SearchHit): void => { + setSessionId(hit.sessionId); + setAgentId(hit.agentId === '' ? 'main' : hit.agentId); + setView('chat'); + setJump({ + turnId: hit.turn === undefined ? undefined : `t${hit.turn}`, + stepId: hit.stepId, + nonce: Date.now(), + }); + }; + return (
@@ -82,6 +104,8 @@ export function App() { {view === 'services' ? ( + ) : view === 'bash' ? ( + ) : view === 'models' ? ( { @@ -89,6 +113,8 @@ export function App() { setView('chat'); }} /> + ) : view === 'search' ? ( + ) : ( <> @@ -103,6 +129,9 @@ export function App() { agentId={agentId} ready={ready} onTrailChange={setTrail} + jump={jump} + onJumpHandled={() => setJump(null)} + onOpenSearchHit={openSearchHit} /> )} { expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); expect(hub.store.get('s2')?.pendingInteraction).toBe('approval'); // The hello goes out with no subscriptions — global facts flow regardless. - const hello = JSON.parse(instances[0]!.sent[0]!) as { type: string; payload: { subscriptions: string[] } }; + const hello = JSON.parse(instances[0]!.sent[0]!) as { + type: string; + payload: { subscriptions: string[] }; + }; expect(hello.type).toBe('client_hello'); expect(hello.payload.subscriptions).toEqual([]); hub.close(); diff --git a/apps/kimi-inspect/src/activity/store.ts b/apps/kimi-inspect/src/activity/store.ts index 80f67cedb9..b68fffc619 100644 --- a/apps/kimi-inspect/src/activity/store.ts +++ b/apps/kimi-inspect/src/activity/store.ts @@ -14,11 +14,8 @@ * `useSyncExternalStore`. */ -import { - GlobalEventsWs, - type SessionWorkFacts, -} from './ws'; import type { WsLikeCtor } from '../channel/wsLike'; +import { GlobalEventsWs, type SessionWorkFacts } from './ws'; export type { SessionWorkFacts }; @@ -131,8 +128,7 @@ export class SessionActivityHub { { busy: item['busy'], mainTurnActive: item['main_turn_active'] === true, - pendingInteraction: - pending === 'approval' || pending === 'question' ? pending : 'none', + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', lastTurnReason: reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason diff --git a/apps/kimi-inspect/src/activity/useSessionActivity.ts b/apps/kimi-inspect/src/activity/useSessionActivity.ts index f4b94722db..cdb83799fe 100644 --- a/apps/kimi-inspect/src/activity/useSessionActivity.ts +++ b/apps/kimi-inspect/src/activity/useSessionActivity.ts @@ -9,8 +9,8 @@ * and a memo-created hub would stay closed for the rest of the page's life. */ -import { useEffect, useState, useSyncExternalStore } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState, useSyncExternalStore } from 'react'; import { useConnection } from '../connection'; import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store'; diff --git a/apps/kimi-inspect/src/activity/ws.ts b/apps/kimi-inspect/src/activity/ws.ts index 18bd2bc51a..6e9b77af30 100644 --- a/apps/kimi-inspect/src/activity/ws.ts +++ b/apps/kimi-inspect/src/activity/ws.ts @@ -206,8 +206,7 @@ function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined { return { busy: p['busy'], mainTurnActive: p['main_turn_active'] === true, - pendingInteraction: - pending === 'approval' || pending === 'question' ? pending : 'none', + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', lastTurnReason: reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined, }; diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts index c601ab3bc9..0043ec73ba 100644 --- a/apps/kimi-inspect/src/audit/audit.test.ts +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -3,21 +3,23 @@ * and tail-preserving truncation used by the chat view's audit panel. */ +import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript'; import { describe, expect, it } from 'vitest'; -import { - EMPTY_AGENT_STATE, - type AgentState, - type TranscriptTurn, -} from '@moonshot-ai/transcript'; - import { diffValue, type DiffNode } from './diff'; import { serializeState } from './serialize'; import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; import { tailTrunc } from './truncate'; function turnItem(n: number): TranscriptTurn { - return { kind: 'turn', turnId: `t${n}`, ordinal: n, state: 'completed', origin: { kind: 'user' }, steps: [] }; + return { + kind: 'turn', + turnId: `t${n}`, + ordinal: n, + state: 'completed', + origin: { kind: 'user' }, + steps: [], + }; } function stateWith(items: readonly TranscriptTurn[]): AgentState { @@ -35,12 +37,19 @@ describe('diffValue', () => { }); it('marks added, removed, and modified object keys', () => { - const node = diffValue({ keep: 1, gone: 'x', changed: 'a' }, { keep: 1, fresh: true, changed: 'b' }); + const node = diffValue( + { keep: 1, gone: 'x', changed: 'a' }, + { keep: 1, fresh: true, changed: 'b' }, + ); expect(node.status).toBe('modified'); expect(node.children?.get('keep')?.status).toBe('unchanged'); expect(node.children?.get('fresh')?.status).toBe('added'); expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' }); - expect(node.children?.get('changed')).toMatchObject({ status: 'modified', prev: 'a', value: 'b' }); + expect(node.children?.get('changed')).toMatchObject({ + status: 'modified', + prev: 'a', + value: 'b', + }); }); it('matches entity arrays by id instead of index', () => { @@ -70,7 +79,7 @@ describe('diffValue', () => { [step('t1.1', 'completed'), step('t1.2', 'completed')], [step('t1.1', 'completed'), step('t1.2', 'running')], ); - expect([...node.children?.keys() ?? []]).toEqual(['t1.1', 't1.2']); + expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']); expect(node.children?.get('t1.1')?.status).toBe('unchanged'); expect(node.children?.get('t1.2')?.status).toBe('modified'); }); @@ -124,8 +133,14 @@ describe('serializeState', () => { const state: AgentState = { ...EMPTY_AGENT_STATE, tasks: new Map([ - ['b-task', { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }], - ['a-task', { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }], + [ + 'b-task', + { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }, + ], + [ + 'a-task', + { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }, + ], ]), pendingInteractions: new Set(['z', 'a']), }; @@ -188,7 +203,9 @@ describe('AuditTrail', () => { expect(entries[1]!.state).toBe(s2); expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); - expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(true); + expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe( + true, + ); expect(entries.every((entry) => entry.summary.length > 0)).toBe(true); }); diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts index abbe260031..efed4cfd2b 100644 --- a/apps/kimi-inspect/src/audit/trail.ts +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -138,7 +138,11 @@ export class AuditTrail { }); } - recordEvent(event: EventAuditEntry['event'], detail: string | undefined, state: AgentState): void { + recordEvent( + event: EventAuditEntry['event'], + detail: string | undefined, + state: AgentState, + ): void { const label = event === 'ack-refresh' ? 'subscribe ack → REST refresh' diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts index 55c0f0d3bc..1064924fea 100644 --- a/apps/kimi-inspect/src/channel/channel.test.ts +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -88,11 +88,11 @@ describe('makeProxy', () => { it('routes methods to call and onXxx members to listen', async () => { const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] }; const channel: IChannel = { - call: async (command: string, args?: unknown[]): Promise => { + call: async (command: string, args?: unknown[]): Promise => { seen.calls.push([command, args ?? []]); return 'ret' as T; }, - listen: (event: string): Event => { + listen: (event: string): Event => { seen.listens.push(event); return () => ({ dispose: () => {} }); }, @@ -137,9 +137,7 @@ describe('probeDebugSurface', () => { it('throws a --debug-endpoints hint when the surface is not mounted (HTTP 404)', async () => { stubProbeFetch(() => ({ ok: false, status: 404 })); - await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow( - /--debug-endpoints/, - ); + await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow(/--debug-endpoints/); }); it('throws an unreachable-server error when fetch itself fails', async () => { diff --git a/apps/kimi-inspect/src/channel/channels.ts b/apps/kimi-inspect/src/channel/channels.ts index f36c6f984e..508e6125fc 100644 --- a/apps/kimi-inspect/src/channel/channels.ts +++ b/apps/kimi-inspect/src/channel/channels.ts @@ -14,9 +14,9 @@ import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; +import type { ServiceProxy } from './channel'; import { DEBUG_RPC_BASE, type InspectClient } from './client'; import { RPCError } from './errors'; -import type { ServiceProxy } from './channel'; /** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */ export type ChannelScope = 'app' | 'session' | 'agent'; diff --git a/apps/kimi-inspect/src/channel/wsLike.ts b/apps/kimi-inspect/src/channel/wsLike.ts index f489ce76bb..440a9e8c77 100644 --- a/apps/kimi-inspect/src/channel/wsLike.ts +++ b/apps/kimi-inspect/src/channel/wsLike.ts @@ -8,7 +8,10 @@ export interface WsLike { readonly readyState: number; send(data: string): void; close(code?: number, reason?: string): void; - addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: never) => void): void; + addEventListener( + type: 'open' | 'message' | 'close' | 'error', + listener: (event: never) => void, + ): void; } export interface WsLikeCtor { diff --git a/apps/kimi-inspect/src/components/BashParserView.tsx b/apps/kimi-inspect/src/components/BashParserView.tsx new file mode 100644 index 0000000000..1532705722 --- /dev/null +++ b/apps/kimi-inspect/src/components/BashParserView.tsx @@ -0,0 +1,279 @@ +/** + * Bash Parser view — a playground for the App-scope `IBashParserService` + * (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`). + * + * left: the bash source textarea plus the parse budget (timeoutMs / + * maxNodes, empty = package default); the `examples…` dropdown + * fills the textarea with curated snippets from the parser's own + * differential fixtures; + * right: the parse result — status badges (hasError / aborted / node + * count) and the syntax tree, one row per node with its type, + * UTF-16 range and (for leaves) the source text. Anonymous tokens + * are dimmed; rows expand/collapse. + * + * Parsing is debounced off the textarea and rides the same `/api/v1/debug` + * channel as every other panel (`klient.core(IBashParserService).parse`) — + * the budgeted parse never throws, `{ ok: false }` means budget exhaustion. + */ + +import { useEffect, useState } from 'react'; + +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser'; + +import { useConnection } from '../connection'; +import { Badge, errorMessage } from '../ui'; + +const DEFAULT_SOURCE = `if [ -f config.sh ]; then + source config.sh && echo "loaded" | tee -a setup.log +else + echo "missing" >&2; exit 1 +fi +`; + +const PARSE_DEBOUNCE_MS = 300; + +/** + * Quick-fill examples, adapted from the parser's own differential fixtures + * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one + * exercises a distinct area of the grammar. The last three probe the + * non-happy paths: deep nesting (a left-associative arithmetic chain, the + * case that once overflowed the DTO conversion) and the error-recovery + * paths that set `hasError`. + */ +const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [ + { + name: 'deep arithmetic (1000 operands)', + // A thousand left-nested binary_expression levels. Deeper chains parse + // fine in-process, but past ~2500 levels the JSON RPC transport itself + // cannot serialize the tree (V8 call-stack limit in JSON.stringify). + source: `echo $((${'1+'.repeat(1000)}1))`, + }, + { + name: 'pipeline & redirects', + source: `git log --oneline | head -20 | tee /tmp/log.txt +find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO +cmd <<< "$input" >out.txt 2>&1 +`, + }, + { + name: 'case statement', + source: `case $x in + a) echo A ;; + b|c) echo BC ;& + foo*|bar) echo match ;; + [a-z]) echo lower ;; + *) echo other ;; +esac +`, + }, + { + name: 'heredoc', + source: `foo() { cat < sum + countNodes(child), 0); +} + +export function BashParserView() { + const { klient } = useConnection(); + const [source, setSource] = useState(DEFAULT_SOURCE); + const [timeoutMs, setTimeoutMs] = useState(''); + const [maxNodes, setMaxNodes] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handle = setTimeout(() => { + klient + .core(IBashParserService) + .parse(source, { + timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs), + maxNodes: maxNodes === '' ? undefined : Number(maxNodes), + }) + .then(setResult, (e: unknown) => { + setResult(null); + setError(errorMessage(e)); + }); + }, PARSE_DEBOUNCE_MS); + return () => { + clearTimeout(handle); + }; + }, [klient, source, timeoutMs, maxNodes]); + + const nodeCount = result !== null && result.ok ? countNodes(result.root) : null; + + return ( +
+
+
+ bash source + +
+ + +
+