diff --git a/.changeset/host-identity-system-prompt.md b/.changeset/host-identity-system-prompt.md deleted file mode 100644 index aec1a9deff..0000000000 --- a/.changeset/host-identity-system-prompt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/agent-core-v2": patch ---- - -Let embedding hosts customize the agent's product name and reply-style guidance in the system prompt when starting the server. diff --git a/.changeset/plugin-quota-note.md b/.changeset/plugin-quota-note.md deleted file mode 100644 index 47be2e0ab0..0000000000 --- a/.changeset/plugin-quota-note.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show a quota consumption note after installing official plugins that bill against plan quota (such as Kimi Datasource). diff --git a/.changeset/plugin-update-notice.md b/.changeset/plugin-update-notice.md deleted file mode 100644 index c08558775b..0000000000 --- a/.changeset/plugin-update-notice.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show an update notice when a turn that used an outdated plugin ends and the Official Marketplace has a newer version; each new version is announced once. Run /plugins to install the latest version. diff --git a/.changeset/v1-custom-agent-files.md b/.changeset/v1-custom-agent-files.md new file mode 100644 index 0000000000..94b79e89ab --- /dev/null +++ b/.changeset/v1-custom-agent-files.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support Markdown-defined custom agents on agent-core. diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md new file mode 100644 index 0000000000..181ba6978a --- /dev/null +++ b/.changeset/v1-secondary-model.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /secondary_model slash command to configure the secondary model used by subagents. 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/FORK.md b/FORK.md index 3ca47678c8..9977274d2c 100644 --- a/FORK.md +++ b/FORK.md @@ -37,21 +37,21 @@ rg -n "disabled_skills|persist_default_model|agents_md_expand_includes|formatTer | Flag | Default | Purpose | | --- | --- | --- | -| `subagent-model-selection` | `false` | Optional exact configured/materializable model aliases on `Agent` and `AgentSwarm`, composed with upstream `primary`/`secondary` when secondary-model is enabled. Env: `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` | +| `subagent-model-selection` | `false` | **v2 engine only.** Optional exact configured/materializable model aliases on `Agent` and `AgentSwarm`, composed with upstream `primary`/`secondary` when secondary-model is enabled. Env: `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION`. The v1-side implementation was retired: upstream `#2232` shipped secondary-model binding + custom agent files on v1 (TUI included), so the default v1 engine now offers upstream's `primary`/`secondary` choices only | ### Upstream foundation (already on main@origin) | Feature | Notes | | --- | --- | -| `#2064` secondary model | Configurable `[secondary_model]`, `primary`/`secondary` tool choices, secondary overlay, startup warning. Keep intact; fork exact-alias selection sits on top. | +| `#2064` secondary model | Configurable `[secondary_model]`, `primary`/`secondary` tool choices, secondary overlay, startup warning. Keep intact; fork exact-alias selection (v2-only) sits on top. | +| `#2232` v1 secondary model + agent files | Custom agent files (user/project/extra/explicit dirs), `--agent`/`--agent-file` in TUI and `kimi -p`, `[secondary_model]` + `KIMI_SECONDARY_MODEL` binding for spawned subagents behind `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL`, `/secondary_model` TUI command, `disallowedTools` deny semantics. Supersedes the fork's retired v1 exact-alias selection. | ### TUI / protocol | Feature | Purpose | | --- | --- | | Stable terminal title | `formatTerminalTitle(workDir)` renders `[host] - ~/path` instead of changing with the session title | -| Subagent model on headers | `subagent.spawned.model` supplies the effective model to Agent cards and AgentSwarm rows | -| v1 spawn event parity | The default v1 engine includes the effective model in `subagent.spawned` | +| Subagent model on cards | The bound model reaches Agent cards and AgentSwarm panels via the child's `agent.status.updated` (upstream's channel). v1's `subagent.spawned` no longer carries `model`; the roster tracker, kimi-web projector, and TUI all learn it from status updates | | Persistent managed quota | The TUI footer shows rolling plan windows and refreshes them after model/session/provider changes; stale responses are ignored | **Managed quota note:** quota is shown only when the active model provider is `managed:kimi-code`. Custom providers never display these account limits. `/usage` and `/status` refresh the TUI footer values. There is no `/usages` command. @@ -103,8 +103,8 @@ Then `kimi -c` can continue the previous healthy session for that workdir. ### Secondary model vs exact-alias selection -- Upstream `secondary-model` remains the foundation for `primary`/`secondary` and `[secondary_model]`. -- Fork `subagent-model-selection` adds exact aliases under a separate experimental flag (default off). +- Upstream `secondary-model` (now including `#2232`'s v1 port) is the foundation for `primary`/`secondary` and `[secondary_model]` on both engines. +- Fork `subagent-model-selection` adds exact aliases under a separate experimental flag (default off) — **v2 engine only**; the v1 implementation was dropped in favor of upstream's. - Permission matching uses the semantic profile name; display labels may include the model for clarity. - Resume model semantics differ by engine: v1 realigns the child to the parent model alias (tool-call `model` is ignored); v2 keeps the journal-bound model and does not rebind from parent tool args. - Known limitations left open: legacy model directory still reserves the tokens `primary` / `secondary` (M-3), and the subagent model directory does not live-reload after config changes without a new session or equivalent restart (M-4). diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 5cee1e0955..8b17b4c2f3 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 0.30.0 + +### Minor Changes + +- [#2255](https://github.com/MoonshotAI/kimi-code/pull/2255) [`67dd031`](https://github.com/MoonshotAI/kimi-code/commit/67dd03149f36be91a0c081e70d8a2d721b0f1c64) Thanks [@he-yufeng](https://github.com/he-yufeng)! - Add a customizable footer status line, configured via `[status_line]` in `tui.toml`. + +### Patch Changes + +- [#2313](https://github.com/MoonshotAI/kimi-code/pull/2313) [`de0ba9d`](https://github.com/MoonshotAI/kimi-code/commit/de0ba9d0654273ff6b028a7a561983ebee4e723e) Thanks [@starquakee](https://github.com/starquakee)! - Stop the turn after repeated invalid tool calls instead of retrying indefinitely. + +- [#2147](https://github.com/MoonshotAI/kimi-code/pull/2147) [`29783e4`](https://github.com/MoonshotAI/kimi-code/commit/29783e471afcf7975852e496907646458264d2e6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show a quota note after installing official plugins that bill against plan quota (such as Kimi Datasource). + +- [#2147](https://github.com/MoonshotAI/kimi-code/pull/2147) [`29783e4`](https://github.com/MoonshotAI/kimi-code/commit/29783e471afcf7975852e496907646458264d2e6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show a notice when an official plugin used in the session has an update available. Run /plugins to install it. + +- [#1857](https://github.com/MoonshotAI/kimi-code/pull/1857) [`cdbd33c`](https://github.com/MoonshotAI/kimi-code/commit/cdbd33c13c7f5cd4c49ec112ee4313b3938a7752) Thanks [@vinlee19](https://github.com/vinlee19)! - Fail fast when account quota or balance is exhausted instead of silently retrying for ~3 minutes. + +- [#2294](https://github.com/MoonshotAI/kimi-code/pull/2294) [`425cfdf`](https://github.com/MoonshotAI/kimi-code/commit/425cfdf53f0fd3b01527f5fba87acff68f49f368) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix garbled line numbers in code blocks. + +- [#2312](https://github.com/MoonshotAI/kimi-code/pull/2312) [`d03a488`](https://github.com/MoonshotAI/kimi-code/commit/d03a4886fdf7c35014c10079a3d417aeb0447d9a) Thanks [@sailist](https://github.com/sailist)! - Remove the 50 MB size limit on file uploads to the built-in server. + ## 0.29.2 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index ec17e64721..cf6ae7f62a 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", + "version": "0.30.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -63,9 +63,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/agent-selection.ts b/apps/kimi-code/src/cli/agent-selection.ts new file mode 100644 index 0000000000..0ffb53d599 --- /dev/null +++ b/apps/kimi-code/src/cli/agent-selection.ts @@ -0,0 +1,42 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; + +import { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/kimi-code-sdk'; + +import type { CLIOptions } from './options'; + +/** + * Resolve which agent profile the launch flags select. + * + * `--agent` carries the profile name directly; `--agent-file` implicitly + * selects the profile the file defines, so the file is parsed here (fatal on + * error) so a bad file fails before any session work. Returns undefined when + * neither flag is present. + */ +export async function resolveAgentProfileSelection( + opts: Pick, + workDir: string, +): Promise { + if (opts.agent !== undefined) return opts.agent; + const agentFile = opts.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + + const path = resolveAgentPath(agentFile, workDir, homedir()); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ path, source: 'explicit', text }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 6ed5551939..a090df4d0f 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -77,7 +77,7 @@ export function createProgram( .addOption( new Option( '--agent ', - 'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.', + 'Agent profile to start the new session with. Custom profiles are discovered from agent directories or loaded via --agent-file. Cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string | undefined) => { if (previous !== undefined) { @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - 'Load an agent definition from a Markdown file and select it (v2 engine only).', + 'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string[] | undefined) => { if ((previous?.length ?? 0) > 0) { 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/options.ts b/apps/kimi-code/src/cli/options.ts index 6e422c3e27..004fd7cabd 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,5 +1,3 @@ -import { isKimiV2Enabled } from './experimental-v2'; - export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; @@ -101,10 +99,10 @@ export function validateOptions( } if ( (opts.agent !== undefined || opts.agentFiles.length > 0) && - (!promptMode || !isKimiV2Enabled(env)) + (opts.session !== undefined || opts.continue) ) { throw new OptionConflictError( - '--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).', + 'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.', ); } if (promptMode && opts.session === '') { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index abee299622..fd795e838d 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -18,6 +18,7 @@ import { resolve } from 'pathe'; import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { resolveAgentProfileSelection } from './agent-selection'; import { isKimiV2Enabled } from './experimental-v2'; import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; @@ -296,6 +297,9 @@ async function resolvePromptSession( stderr: PromptOutput, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { + // `--agent`/`--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths never forward a + // profile — the bound agent is restored from the session itself. if (opts.session !== undefined) { const sessions = await harness.listSessions({ sessionId: opts.session, workDir }); const target = sessions[0]; @@ -365,12 +369,15 @@ async function resolvePromptSession( stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); } + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const model = requireConfiguredModel(opts.model, defaultModel); const session = await harness.createSession({ workDir, model, permission: 'auto', additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + agentProfile, + agentFiles: opts.agentFiles?.length ? opts.agentFiles : undefined, drainAgentTasksOnStop: true, }); installHeadlessHandlers(session); diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce711..220aa749ff 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,8 @@ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; +import { resolveAgentProfileSelection } from './agent-selection'; +import { isKimiV2Enabled } from './experimental-v2'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; @@ -60,7 +64,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 +80,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, @@ -101,8 +111,12 @@ export async function runShell( configWarning = combineStartupNotice(configWarning, warning); } const configMs = Date.now() - configStartedAt; + // Resolve --agent/--agent-file once for the startup session; validateOptions + // has already rejected them alongside --session/--continue. + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const tui = new KimiTUI(harness, { cliOptions: opts, + agentProfile, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 540ef778cf..9318b85395 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -8,6 +8,7 @@ * `startServer`). */ +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; @@ -266,6 +267,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,7 +295,7 @@ 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 = { @@ -315,8 +322,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/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index ff0c96c590..0bf117b038 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -293,27 +293,14 @@ async function resolveNativeSession( } } - // `--agent` / `--agent-file` bind an explicit profile; without them the - // historical setModel path (default profile on first bind) is kept. A - // same-name re-select on a resumed session keeps the profile and only applies - // an explicitly requested model; a different name is rejected by the - // engine's first-bind guard inside `bind`. - const applyProfileSelection = async ( + // `--agent` / `--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths only apply an + // explicitly requested model — the bound profile is restored by the engine. + const applyModelOverride = async ( profile: IAgentProfileService, model: string | undefined, ): Promise => { - if (agentProfileName !== undefined) { - if (profile.data().profileName === agentProfileName) { - if (model !== undefined) await profile.setModel(model); - return; - } - await profile.bind({ - profile: agentProfileName, - model: requireConfiguredModel(model ?? profile.getModel(), defaultModel), - }); - } else if (model !== undefined) { - await profile.setModel(model); - } + if (model !== undefined) await profile.setModel(model); }; const resumeById = async (id: string): Promise => { @@ -353,7 +340,7 @@ async function resolveNativeSession( const session = await resumeById(opts.session); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - await applyProfileSelection(profile, opts.model); + await applyModelOverride(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -372,7 +359,7 @@ async function resolveNativeSession( const session = await resumeById(previous.id); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - await applyProfileSelection(profile, opts.model); + await applyModelOverride(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..2422dc5648 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,6 +1,8 @@ import { effectiveModelAlias, + SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, + type KimiConfig, type ModelAlias, type PermissionMode, type Session, @@ -250,6 +252,25 @@ export async function handleModelCommand(host: SlashCommandHost, args: string): showModelPicker(host, alias); } +export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise { + const alias = args.trim(); + await refreshModelsForPicker(host); + const models = pickerModelsForHost(host); + if (Object.keys(models).length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', + ); + return; + } + if (alias.length > 0 && models[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + const secondary = (await host.harness.getConfig()).secondaryModel; + showSecondaryModelPicker(host, models, secondary?.model ?? '', secondary?.defaultEffort, alias); +} + export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { const alias = host.state.appState.model; const model = host.state.appState.availableModels[alias]; @@ -390,13 +411,22 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise ); } -export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const models = Object.fromEntries( - Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ - alias, - effectiveModelForHost(host, model), - ]), +/** + * The models a picker may offer: the user's configured aliases with + * host-effective provider resolution applied, minus the synthesized + * `__secondary__` derived entry — a runtime artifact of the `[secondary_model]` + * recipe that must never be selectable as a primary or secondary model. + */ +function pickerModelsForHost(host: SlashCommandHost): Record { + return Object.fromEntries( + Object.entries(host.state.appState.availableModels) + .filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS) + .map(([alias, model]) => [alias, effectiveModelForHost(host, model)]), ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const models = pickerModelsForHost(host); const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( @@ -553,6 +583,99 @@ async function persistModelSelection( return true; } +// --------------------------------------------------------------------------- +// Secondary model (`/secondary_model`) +// --------------------------------------------------------------------------- + +function showSecondaryModelPicker( + host: SlashCommandHost, + models: Record, + currentValue: string, + currentEffort: string | undefined, + selectedValue?: string, +): void { + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue, + selectedValue, + currentThinkingEffort: currentEffort ?? 'off', + title: ' Select a secondary model (subagents)', + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performSecondaryModelSwitch(host, alias, thinking); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Persist-first, then live-apply: the synthesized derived entry only exists in + * the core config after a reload. No session-only variant — a session-local + * recipe with patch fields would bind a derived alias the core config cannot + * resolve. + */ +async function performSecondaryModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise { + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + let updatedConfig: KimiConfig; + try { + updatedConfig = await host.harness.setConfig({ + secondaryModel: { model: alias, defaultEffort: effort }, + }); + } catch (error) { + host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); + return; + } + if (host.session !== undefined) { + try { + await host.session.applyPersistedSecondaryModel(); + } catch (error) { + host.showError( + `Saved ${displayName} as the secondary model, but failed to apply it to this session: ${formatErrorMessage(error)}`, + ); + return; + } + } + host.setAppState({ availableModels: updatedConfig.models ?? {} }); + // Report the effective binding from the reloaded config, not the picked + // value: KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT override the recipe at + // runtime, and the session binds the overlaid snapshot (mirrors how + // /model displays the effective alias read back from the session). + const effective = updatedConfig.secondaryModel; + const envOverrides: string[] = []; + if (effective?.model !== undefined && effective.model !== alias) { + envOverrides.push(`KIMI_SECONDARY_MODEL=${effective.model}`); + } + if (effective?.defaultEffort !== undefined && effective.defaultEffort !== effort) { + envOverrides.push(`KIMI_SECONDARY_EFFORT=${effective.defaultEffort}`); + } + if (envOverrides.length > 0 && effective?.model !== undefined) { + const effectiveName = modelDisplayName( + effective.model, + updatedConfig.models?.[effective.model], + ); + host.showStatus( + `Saved ${displayName} as the secondary model, but ${envOverrides.join(' and ')} ` + + `overrides it at runtime — subagents bind ${effectiveName} until the env var is unset.`, + 'warning', + ); + return; + } + host.showStatus( + host.session === undefined + ? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.` + : `Secondary model set to ${displayName} with thinking ${effort}.`, + 'success', + ); +} + function showThemePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new ThemeSelectorComponent({ diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..b2feffaebe 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -29,6 +29,7 @@ import { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, @@ -71,6 +72,7 @@ export { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showModelPicker, @@ -303,6 +305,9 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; + case 'secondary_model': + await handleSecondaryModelCommand(host, args); + return; case 'effort': await handleEffortCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..2cea404918 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -184,6 +184,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'secondary_model', + aliases: [], + description: 'Configure the secondary model for subagents', + priority: 90, + availability: 'always', + experimentalFlag: 'secondary-model', + }, { name: 'effort', aliases: ['thinking'], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..2c0010334b 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) { @@ -48,6 +52,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 a4082e9113..7bbb914254 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -18,6 +18,10 @@ import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; import { buildManagedUsageFooterView } from '#/tui/utils/managed-usage-footer'; +import { + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; import { createGitStatusCache, formatGitBadgeBase, @@ -31,6 +35,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; @@ -220,6 +226,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 @@ -237,6 +244,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 { @@ -246,9 +254,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 @@ -279,23 +303,132 @@ 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 / plan quota (bottom-left) + context (right) ── + const contextText = formatContextStatus( + state.contextUsage, + state.contextTokens, + state.maxContextTokens, + ); + const contextWidth = visibleWidth(contextText); + // A transient hint (e.g. the exit double-tap prompt) outranks the quota + // readout for the left slot — it is short-lived and user-actionable. + // Quota itself is gated on the active model provider so a leftover cache + // never leaks onto a non-managed model. + const providerKey = state.availableModels[state.model]?.provider; + const quotaText = + this.transientHint || !isManagedUsageProvider(providerKey) + ? null + : formatManagedUsageStatus(state.managedUsage, state.managedUsageError, colors); + const leftText = this.transientHint + ? chalk.hex(colors.warning).bold(this.transientHint) + : quotaText; + let line2: string; + if (leftText !== null) { + const maxLeftWidth = Math.max(0, width - contextWidth - 1); + const shownLeft = + visibleWidth(leftText) <= maxLeftWidth + ? leftText + : truncateToWidth(leftText, maxLeftWidth, '…'); + const leftWidth = visibleWidth(shownLeft); + const pad = Math.max(0, width - leftWidth - contextWidth); + line2 = shownLeft + ' '.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; @@ -310,92 +443,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; - } - - 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, '…'); - } + if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)]; - // ── Line 2: transient hint / plan quota (bottom-left) + context (right) ── - const contextText = formatContextStatus( - state.contextUsage, - state.contextTokens, - state.maxContextTokens, - ); - const contextWidth = visibleWidth(contextText); - // A transient hint (e.g. the exit double-tap prompt) outranks the quota - // readout for the left slot — it is short-lived and user-actionable. - // Quota itself is gated on the active model provider so a leftover cache - // never leaks onto a non-managed model. - const providerKey = state.availableModels[state.model]?.provider; - const quotaText = - this.transientHint || !isManagedUsageProvider(providerKey) - ? null - : formatManagedUsageStatus(state.managedUsage, state.managedUsageError, colors); - const leftText = this.transientHint - ? chalk.hex(colors.warning).bold(this.transientHint) - : quotaText; - let line2: string; - if (leftText !== null) { - const maxLeftWidth = Math.max(0, width - contextWidth - 1); - const shownLeft = - visibleWidth(leftText) <= maxLeftWidth - ? leftText - : truncateToWidth(leftText, maxLeftWidth, '…'); - const leftWidth = visibleWidth(shownLeft); - const pad = Math.max(0, width - leftWidth - contextWidth); - line2 = shownLeft + ' '.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/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 64646e0229..64537df22e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -67,6 +67,8 @@ export interface ModelSelectorOptions { /** Live thinking effort of the currently active model (e.g. 'off', 'on', * 'high'). Used to highlight the active segment for the current model. */ readonly currentThinkingEffort: ThinkingEffort; + /** Overrides the default ' Select a model' title line. */ + readonly title?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -289,7 +291,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.boldFg('primary', this.opts.title ?? ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), ]; if (this.opts.warning !== undefined) { diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 8adc5efa14..d94de3b06d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -40,6 +40,9 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinkingEffort: string; + /** Forwarded to each inner selector; overrides the default ' Select a model' + * title line (e.g. the secondary-model picker). */ + readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -180,6 +183,7 @@ function makeSelector( currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinkingEffort: opts.currentThinkingEffort, + title: opts.title, searchable: true, providerSwitchHint: true, warning: opts.warning, diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index b358be5138..1fe4961e9f 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -186,11 +186,9 @@ export class AgentGroupComponent extends Container { const desc = snap.toolCallDescription || '(no description)'; const tail = formatLineTail(snap); const namePart = currentTheme.fg('primary', agentType); - const modelPart = - snap.model !== undefined && snap.model.length > 0 ? dim(`· ${snap.model} `) : ''; const descPart = dim(`· ${desc}`); const stats = formatStats(snap); - const line1 = ` ${branch1} ${namePart} ${modelPart}${descPart}${stats}${tail}`; + const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; this.bodyContainer.addChild(new Text(line1, 0, 0)); // Second-level line: latest activity, or Error for failures. @@ -303,7 +301,9 @@ function formatBreakdownParts(counts: PhaseCounts): string[] { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + const parts: string[] = []; + if (snap.model !== undefined) parts.push(snap.model); + parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); return currentTheme.dim(` · ${parts.join(' · ')}`); diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 4b1ddbdab5..baeb14f003 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -113,7 +113,6 @@ interface AgentSwarmMember { ticks: number; itemText: string; latestModelText: string; - model?: string; completedText?: string; failureText?: string; cancelledLabelText?: string; @@ -191,6 +190,7 @@ export class AgentSwarmProgressComponent implements Component { private description: string; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; + private readonly memberModels = new Map(); private inputComplete = false; private failed = false; private aborted = false; @@ -225,6 +225,25 @@ export class AgentSwarmProgressComponent implements Component { this.activitySpinnerText = provider; } + /** + * Record a member's bound model (from its status update). The header shows + * the model only while every reported member agrees — mixed bindings + * (e.g. resumed agents that kept an older binding alongside freshly spawned + * ones) leave the header without a model rather than attributing one + * member's model to the whole swarm. + */ + setMemberModel(agentId: string, modelDisplay: string): void { + if (modelDisplay.length === 0) return; + this.memberModels.set(agentId, modelDisplay); + } + + private headerModelDisplay(): string { + const models = [...this.memberModels.values()]; + const first = models[0]; + if (first === undefined) return ''; + return models.every((model) => model === first) ? first : ''; + } + markToolCallEnded(): void { this.toolCallActive = false; this.activitySpinnerText = undefined; @@ -291,12 +310,10 @@ export class AgentSwarmProgressComponent implements Component { readonly agentId: string; readonly swarmIndex?: number; readonly description?: string | undefined; - readonly model?: string | undefined; }): void { const member = this.findMemberForSubagent(input.agentId, input.swarmIndex); if (member === undefined) return; member.agentId = input.agentId; - if (input.model !== undefined) member.model = input.model; if (member.phase === 'pending') member.phase = 'queued'; this.startAnimationIfNeeded(); } @@ -484,39 +501,19 @@ export class AgentSwarmProgressComponent implements Component { this.description.length > 0 ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) : ''; - const sharedModel = this.sharedMemberModel(); - const modelLabel = - sharedModel !== undefined - ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(sharedModel) + const modelDisplay = this.headerModelDisplay(); + const model = + modelDisplay.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(modelDisplay) : ''; const prefixText = '─ '; const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); - const label = truncateToWidth(title + description + modelLabel, labelWidth); + const label = truncateToWidth(title + description + model, labelWidth); const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); } - /** - * Returns the model alias shared by every member that has one, or undefined - * when members have no model or disagree. Used to surface the swarm's model - * in the header without per-member clutter. - */ - private sharedMemberModel(): string | undefined { - let shared: string | undefined; - let seen = false; - for (const member of this.members) { - if (member.model === undefined) continue; - if (!seen) { - shared = member.model; - seen = true; - } else if (member.model !== shared) { - return undefined; - } - } - return seen ? shared : undefined; - } - private renderStatusLine(width: number): string { const status = totalStatus(this.members, { failed: this.failed, diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 52e8b480c4..d168f0728a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -93,6 +93,8 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; + /** Display name of the model the subagent is bound to, when known (live only). */ + readonly model?: string; readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly elapsedSeconds: number | undefined; @@ -100,7 +102,6 @@ export interface ToolCallSubagentSnapshot { readonly isError: boolean; readonly errorText: string | undefined; readonly latestActivity: string | undefined; - readonly model: string | undefined; } /** @@ -846,16 +847,20 @@ export class ToolCallComponent extends Container { // ── Subagent API (called by KimiTUI event routing) ─────────────── setSubagentMeta(agentId: string, agentName?: string, model?: string): void { + // Never let a model-less event wipe an already-known model display (the + // status-derived one from `updateSubagentMetrics`): v1's `subagent.spawned` + // no longer carries a model, so most events arrive with `undefined`. + const nextModel = model ?? (this.subagentAgentId === agentId ? this.subagentModel : undefined); if ( this.subagentAgentId === agentId && this.subagentAgentName === agentName && - this.subagentModel === model + this.subagentModel === nextModel ) { return; } this.subagentAgentId = agentId; this.subagentAgentName = agentName; - this.subagentModel = model; + this.subagentModel = nextModel; this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -908,6 +913,7 @@ export class ToolCallComponent extends Container { toolName: this.toolCall.name, toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), agentName: this.subagentAgentName, + model: this.subagentModel, phase: derivedPhase, toolCount: finished, elapsedSeconds: this.getSubagentElapsedSeconds(), @@ -915,7 +921,6 @@ export class ToolCallComponent extends Container { isError: derivedPhase === 'failed', errorText, latestActivity, - model: this.subagentModel, }; } @@ -1177,6 +1182,7 @@ export class ToolCallComponent extends Container { updateSubagentMetrics(payload: { contextTokens?: number | undefined; usage?: TokenUsage | undefined; + modelDisplay?: string | undefined; }): void { if (payload.contextTokens !== undefined && payload.contextTokens > 0) { this.subagentContextTokens = payload.contextTokens; @@ -1184,6 +1190,9 @@ export class ToolCallComponent extends Container { if (payload.usage !== undefined) { this.subagentUsage = payload.usage; } + if (payload.modelDisplay !== undefined) { + this.subagentModel = payload.modelDisplay; + } this.headerText.setText(this.buildHeader()); this.invalidate(); this.notifySnapshotChange(); @@ -1773,15 +1782,11 @@ export class ToolCallComponent extends Container { const descriptionPlain = description.length > 0 ? ` (${description})` : ''; const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; const statsText = this.formatSingleSubagentStatsText(); - const modelChip = - this.subagentModel !== undefined && this.subagentModel.length > 0 - ? currentTheme.dim(` · ${this.subagentModel}`) - : ''; if (isDone) { - return `${marker}${currentTheme.boldFg('success', labelText)}${modelChip} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; } const stats = currentTheme.dim(statsText); - return `${marker}${label} ${status}${descriptionText}${modelChip}${stats}`; + return `${marker}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { @@ -1803,9 +1808,9 @@ export class ToolCallComponent extends Container { } private formatSingleSubagentStatsText(): string { - const parts = [ - `${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`, - ]; + const parts: string[] = []; + if (this.subagentModel !== undefined) parts.push(this.subagentModel); + parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); const elapsed = this.getSubagentElapsedSeconds(); if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); const tokens = diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 71d6e1b5b9..c29cb2cb2d 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -6,6 +6,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { @@ -24,11 +25,36 @@ const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; type Colorize = (text: string) => string; +export interface ManagedUsageWindow { + readonly duration: number; + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + export interface ManagedUsageRow { - readonly label: string; + readonly name?: string; + readonly window?: ManagedUsageWindow; readonly used: number; readonly limit: number; - readonly resetHint?: string; + readonly resetAt?: string; +} + +function usageRowLabel(row: ManagedUsageRow): string { + const window = row.window; + if (window !== undefined) { + if (window.unit === 'week') return 'Weekly limit'; + return `${String(window.duration)}${window.unit[0] ?? ''} limit`; + } + return row.name ?? 'Limit'; +} + +function usageRowResetHint(row: ManagedUsageRow): string | undefined { + const resetAt = row.resetAt; + if (resetAt === undefined) return undefined; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed)) return undefined; + const diffSec = Math.floor((parsed - Date.now()) / 1000); + if (diffSec <= 0) return 'reset'; + return `resets in ${formatDuration(diffSec)}`; } export interface BoosterWalletInfo { @@ -130,17 +156,20 @@ function buildManagedUsageSection( rows.push(...limits); const usedRatio = (r: ManagedUsageRow): number => r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; - const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); + const labels = rows.map((r) => usageRowLabel(r)); + const labelWidth = Math.max(10, ...labels.map((l) => l.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); const out: string[] = [accent('Plan usage')]; - for (const row of rows) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); - const label = row.label.padEnd(labelWidth, ' '); - const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; + const label = labels[i]!.padEnd(labelWidth, ' '); + const resetHint = usageRowResetHint(row); + const resetStr = resetHint !== undefined ? ` ${muted(resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); } return out; 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/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce8..bf8699ff2c 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -85,6 +85,12 @@ export class AuthFlowController { ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, + // The post-login session is still the startup session: carry the + // --agent/--agent-file binding resolved at launch. + agentProfile: host.options.startup.agentProfile, + agentFiles: host.options.startup.agentFiles?.length + ? [...host.options.startup.agentFiles] + : undefined, }; if (host.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...host.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index eb1107cd36..d632b50288 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -9,6 +9,7 @@ import { agentSwarmDescriptionFromArgs, agentSwarmGridHeightForTerminalRows, } from '../components/messages/agent-swarm-progress'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { MAIN_AGENT_ID } from '../constant/kimi-tui'; import type { BackgroundAgentMetadata, @@ -123,9 +124,24 @@ export class SubAgentEventHandler { } else if (event.type === 'agent.status.updated') { const usageObj = event.usage; const totalUsage = usageObj?.total ?? usageObj?.currentTurn; + // The bound model alias rides every child status update (emitted right + // after spawn); surface it on the subagent card. `modelDisplayName` + // falls back to the alias itself when the entry is unknown (e.g. the + // synthesized `__secondary__` derived entry is missing). Persist the + // resolved display back into `subagentInfo` so later events — which + // re-apply `setSubagentMeta` from it — cannot wipe the display (v1's + // `subagent.spawned` no longer carries a model). + const resolvedModel = + event.model === undefined + ? undefined + : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]); + if (resolvedModel !== undefined && info.model !== resolvedModel) { + this.subagentInfo.set(childAgentId, { ...info, model: resolvedModel }); + } toolCall.updateSubagentMetrics({ contextTokens: event.contextTokens, usage: totalUsage, + modelDisplay: resolvedModel, }); } return true; @@ -408,7 +424,6 @@ export class SubAgentEventHandler { progress.registerSubagent({ agentId: event.subagentId, swarmIndex: event.swarmIndex, - model: event.model, }); })) { return; @@ -507,6 +522,17 @@ export class SubAgentEventHandler { progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); } else if (event.type === 'tool.call.started') { progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } else if (event.type === 'agent.status.updated' && event.model !== undefined) { + // The bound model alias rides every child status update (emitted right + // after spawn). The panel shows the model once in the header instead of + // per cell — but only while every reported member agrees on it. + // `modelDisplayName` falls back to the alias itself when the entry is + // unknown (e.g. the synthesized `__secondary__` derived entry is + // missing). + progress.setMemberModel( + subagentId, + modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + ); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 939ddc85b8..3c098e538f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -174,6 +174,8 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + /** Profile name resolved from cliOptions --agent/--agent-file (see resolveAgentProfileSelection). */ + readonly agentProfile?: string; readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; @@ -230,6 +232,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, @@ -392,6 +395,8 @@ export class KimiTUI { auto: startupInput.cliOptions.auto, plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, + agentProfile: startupInput.agentProfile, + agentFiles: startupInput.cliOptions.agentFiles, startupNotice: startupInput.startupNotice, }, }; @@ -749,6 +754,10 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + // --agent/--agent-file bind the startup session only; sessions created + // later in this process fall back to the default profile. + agentProfile: startup.agentProfile, + agentFiles: startup.agentFiles?.length ? [...startup.agentFiles] : undefined, }; if (this.state.appState.additionalDirs.length > 0) { createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 64fba930fa..30de918e31 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 { ManagedUsageReport } from './components/messages/usage-panel'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -53,6 +53,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; @@ -252,6 +254,10 @@ export interface TUIStartupOptions { readonly auto: boolean; readonly plan: boolean; readonly model?: string; + /** Resolved profile name from --agent/--agent-file; bound to the startup session only. */ + readonly agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + readonly agentFiles?: readonly string[]; readonly startupNotice?: string; } diff --git a/apps/kimi-code/src/tui/utils/managed-usage-footer.ts b/apps/kimi-code/src/tui/utils/managed-usage-footer.ts index 91fcde5d74..baab8423f4 100644 --- a/apps/kimi-code/src/tui/utils/managed-usage-footer.ts +++ b/apps/kimi-code/src/tui/utils/managed-usage-footer.ts @@ -17,6 +17,19 @@ export type ManagedUsageFooterView = | { readonly kind: 'error'; readonly text: string } | { readonly kind: 'ok'; readonly parts: readonly ManagedUsageFooterPart[] }; +/** + * Compact row label for the footer, e.g. `1w` / `5h`. Mirrors the usage + * panel's `usageRowLabel` but keeps the short form (footer space is tight). + */ +function compactRowLabel(row: ManagedUsageRow): string { + const window = row.window; + if (window !== undefined) { + if (window.unit === 'week') return '1w'; + return `${String(window.duration)}${window.unit[0] ?? ''}`; + } + return row.name ?? 'Limit'; +} + /** * Build the structured left-slot content for the footer plan-quota line. * Returns null when there is nothing to render (no error and no rows). @@ -37,9 +50,8 @@ export function buildManagedUsageFooterView( const parts = rows.map((row) => { const pct = usagePercent(row.used, row.limit); - const label = row.label.replace(/\s+limit$/i, ''); const severity = ratioSeverity(row.limit > 0 ? row.used / row.limit : 0); - return { text: `${label}: ${String(pct)}%`, severity }; + return { text: `${compactRowLabel(row)}: ${String(pct)}%`, severity }; }); return { kind: 'ok', parts }; } 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/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 8bfe8561ef..2adb0c2171 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: top-level CLI option parsing, validation, and help discovery. + * Responsibilities: accepted arguments map to CLIOptions and invalid combinations fail early. + * Wiring: Commander is real; command handlers and output sinks are local test boundaries. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/options.test.ts + */ + import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; @@ -393,6 +400,14 @@ describe('CLI options parsing', () => { }); describe('--agent / --agent-file', () => { + it('describes agent selectors as new-session-only', () => { + const help = createProgram('0.1.0-test', () => {}, () => {}).helpInformation(); + const normalizedHelp = help.replaceAll(/\s+/g, ' '); + + expect(normalizedHelp).toContain('Agent profile to start the new session with.'); + expect(normalizedHelp).not.toContain('print-mode invocation'); + }); + it('parses a single --agent', () => { const opts = parse(['-p', 'hi', '--agent', 'reviewer']); expect(opts.agent).toBe('reviewer'); @@ -437,6 +452,38 @@ describe('CLI options parsing', () => { ); }); + it('rejects --agent-file with --session', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent-file with --continue', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --session', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --continue in shell mode', () => { + const opts = parse(['--agent', 'reviewer', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + it('rejects empty agent values', () => { const opts = parse(['-p', 'hi', '--agent', ' ']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -449,20 +496,14 @@ describe('CLI options parsing', () => { expect(() => validateOptions(opts)).toThrow('Agent file path cannot be empty.'); }); - it('rejects the flags in shell mode', () => { - const opts = parse(['--agent', 'reviewer']); - expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow( - '--agent/--agent-file are only available with the v2 engine', - ); + it('accepts the flags in shell mode', () => { + expect(validateOptions(parse(['--agent', 'reviewer']), {}).uiMode).toBe('shell'); + expect(validateOptions(parse(['--agent-file', 'a.md']), {}).uiMode).toBe('shell'); }); - it('rejects the flags in prompt mode without the v2 engine flag', () => { + it('accepts the flags in prompt mode without the v2 engine flag', () => { const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); - expect(() => validateOptions(opts, {})).toThrow(OptionConflictError); - expect(() => validateOptions(opts, {})).toThrow( - '--agent/--agent-file are only available with the v2 engine', - ); + expect(validateOptions(opts, {}).uiMode).toBe('print'); }); it('accepts the flags in prompt mode with the v2 engine flag', () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 4bad127d89..d71e88b23f 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,3 +1,14 @@ +/** + * Scenario: print-mode session startup and resume routing. + * Responsibilities: CLI options are translated into the SDK session contract and output is rendered. + * Wiring: the SDK/telemetry/process boundaries are mocked; the print driver is real. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/run-prompt.test.ts + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -282,6 +293,32 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('selects the profile declared by an explicit agent file for a fresh v1 session', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-run-prompt-agent-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + try { + await runPrompt(opts({ agentFiles: [agentFile] }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentProfile: 'reviewer', + agentFiles: [agentFile], + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { vi.useFakeTimers(); try { @@ -629,6 +666,17 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('does not forward an agent profile when resuming a concrete v1 session', async () => { + // validateOptions rejects --agent with --session; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ session: 'ses_existing', agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -901,6 +949,17 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('does not forward an agent profile when continuing a previous v1 session', async () => { + // validateOptions rejects --agent with --continue; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ continue: true, agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a267..7e62606265 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -31,6 +31,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: {}, @@ -67,6 +68,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, @@ -78,17 +94,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); }, }; }); @@ -163,6 +173,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', @@ -245,6 +319,35 @@ describe('runShell', () => { }); }); + it('resolves the --agent profile into the TUI startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: 'reviewer', + agentFiles: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ agentProfile: 'reviewer' }); + }); + it('forwards skillsDirs from CLI options to the harness', 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..490aa83ec1 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(); diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index d23497b5d1..1b51bfc535 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -542,6 +542,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..bf875b52b4 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -166,6 +166,7 @@ describe('built-in slash command registry', () => { 'plan', 'reload', 'reload-tui', + 'secondary_model', 'sessions', 'settings', 'status', @@ -188,4 +189,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); + + it('gates secondary_model behind the secondary-model experiment, always available', () => { + const command = findBuiltInSlashCommand('secondary_model'); + expect(command).toBeDefined(); + expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts new file mode 100644 index 0000000000..81b309ef01 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -0,0 +1,230 @@ +/** + * Scenario: /secondary_model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence, live apply, and effective-model state refresh. + * Wiring: real command and selector with the SDK/session boundaries stubbed by a small host rig. + * Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/secondary-model.test.ts + */ +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { handleSecondaryModelCommand } from '#/tui/commands/config'; +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; + +interface PickerOptions { + readonly models: Record; + readonly currentValue: string; + readonly currentThinkingEffort: string; + readonly title?: string; + readonly onSelect: (selection: { alias: string; thinking: ThinkingEffort }) => void; +} + +function model(name: string): ModelAlias { + return { + provider: 'test', + model: name, + maxContextSize: 200_000, + displayName: name, + } as unknown as ModelAlias; +} + +function makeHost(options?: { + readonly withSession?: boolean; + readonly secondaryModel?: { model: string; defaultEffort?: string }; + readonly persistedModels?: Record; + /** The secondary model the reloaded config carries — env overlays win. */ + readonly effectiveSecondary?: { model: string; defaultEffort?: string }; +}) { + const session = options?.withSession === false + ? undefined + : { applyPersistedSecondaryModel: vi.fn(async () => {}) }; + const appState = { + availableModels: { + k2: model('k2'), + cheap: model('cheap'), + // The synthesized derived entry must never be selectable. + '__secondary__': model('cheap'), + } as Record, + availableProviders: {}, + transcriptEntries: [], + }; + const host = { + state: { + appState, + transcriptEntries: [], + }, + authFlow: { + refreshOAuthProviderModels: vi.fn(async () => undefined), + }, + harness: { + getConfig: vi.fn(async () => ({ + providers: {}, + secondaryModel: options?.secondaryModel, + })), + setConfig: vi.fn(async () => ({ + providers: {}, + models: options?.persistedModels, + secondaryModel: options?.effectiveSecondary, + })), + }, + session, + setAppState: vi.fn((patch) => Object.assign(appState, patch)), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + getConfig: ReturnType; + setConfig: ReturnType; + }; + mountEditorReplacement: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + showNotice: ReturnType; + }; + return { host, session }; +} + +function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const component = host.mountEditorReplacement.mock.calls[0]![0]; + expect(component).toBeInstanceOf(TabbedModelSelectorComponent); + return (component as unknown as { opts: PickerOptions }).opts; +} + +describe('handleSecondaryModelCommand', () => { + it('opens the picker filtered to user models, with the configured recipe as current', async () => { + const { host } = makeHost({ secondaryModel: { model: 'cheap', defaultEffort: 'high' } }); + + await handleSecondaryModelCommand(host, ''); + + const opts = mountedPicker(host); + expect(Object.keys(opts.models)).toEqual(['k2', 'cheap']); + expect(opts.currentValue).toBe('cheap'); + expect(opts.currentThinkingEffort).toBe('high'); + expect(opts.title).toContain('secondary model'); + }); + + it('persists first, then live-applies the selection to the session', async () => { + const { host, session } = makeHost(); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'high' }, + }); + expect(session!.applyPersistedSecondaryModel).toHaveBeenCalledWith(); + expect(host.harness.setConfig.mock.invocationCallOrder[0]).toBeLessThan( + session!.applyPersistedSecondaryModel.mock.invocationCallOrder[0]!, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('refreshes the effective model map after a live secondary-model switch', async () => { + const { host } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); + }); + + it('warns with the env-overridden effective binding instead of the picked model', async () => { + // KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT win over the persisted + // recipe: the reloaded config carries the overlaid values, and the status + // message must name them rather than echo the pick. + const { host } = makeHost({ + effectiveSecondary: { model: 'cheap', defaultEffort: 'low' }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + const [message, color] = host.showStatus.mock.calls[0]!; + expect(message).toContain('KIMI_SECONDARY_MODEL=cheap'); + expect(message).toContain('KIMI_SECONDARY_EFFORT=low'); + expect(color).toBe('warning'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('keeps the current effective model map when live apply fails', async () => { + const { host, session } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + session!.applyPersistedSecondaryModel.mockRejectedValueOnce(new Error('apply failed')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('cheap'); + }); + + it('persists only when there is no session', async () => { + const { host } = makeHost({ withSession: false }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'off' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'off' }, + }); + expect(host.showStatus.mock.calls[0]![0]).toContain('new sessions'); + }); + + it('rejects an unknown alias argument without opening the picker', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'nope'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: nope'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('rejects the synthesized derived alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, '__secondary__'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: __secondary__'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('shows a notice when no models are configured', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = {}; + + await handleSecondaryModelCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); +}); 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/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 308bc7bf59..f1e6f46cd6 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -215,8 +215,8 @@ describe('FooterComponent managed quota', () => { model: 'kimi-k2', availableModels: { 'kimi-k2': managedKimiModel }, managedUsage: { - summary: { label: '1w limit', used: 12, limit: 100 }, - limits: [{ label: '5h limit', used: 40, limit: 100 }], + summary: { window: { duration: 1, unit: 'week' }, used: 12, limit: 100 }, + limits: [{ window: { duration: 5, unit: 'hour' }, used: 40, limit: 100 }], }, managedUsageError: null, }; @@ -241,8 +241,8 @@ describe('FooterComponent managed quota', () => { }, // Leftover cache from a previous managed model must not leak. managedUsage: { - summary: { label: '1w limit', used: 12, limit: 100 }, - limits: [{ label: '5h limit', used: 40, limit: 100 }], + summary: { window: { duration: 1, unit: 'week' }, used: 12, limit: 100 }, + limits: [{ window: { duration: 5, unit: 'hour' }, used: 40, limit: 100 }], }, }; const footer = new FooterComponent(state); @@ -259,8 +259,8 @@ describe('FooterComponent managed quota', () => { model: 'kimi-k2', availableModels: { 'kimi-k2': managedKimiModel }, managedUsage: { - summary: { label: '1w limit', used: 12, limit: 100 }, - limits: [{ label: '5h limit', used: 40, limit: 100 }], + summary: { window: { duration: 1, unit: 'week' }, used: 12, limit: 100 }, + limits: [{ window: { duration: 5, unit: 'hour' }, used: 40, limit: 100 }], }, }; const footer = new FooterComponent(state); 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..f6ffc64961 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 @@ -132,6 +132,22 @@ describe('TabbedModelSelectorComponent', () => { expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + it('renders the default title, and a custom title when provided', () => { + expect(strip(make().component.render(120).join('\n'))).toContain('Select a model'); + + const titled = new TabbedModelSelectorComponent({ + models: { k2: model('Kimi K2', 'managed:kimi-code') }, + currentValue: 'k2', + currentThinkingEffort: 'off', + title: ' Select a secondary model (subagents)', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = strip(titled.render(120).join('\n')); + expect(out).toContain('Select a secondary model (subagents)'); + expect(out).not.toContain('Select a model '); + }); + it('keeps the tab strip between hint and list when a warning line is present', () => { const component = new TabbedModelSelectorComponent({ models: { diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index add7a83097..852f88fad4 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -91,6 +91,26 @@ describe('AgentGroupComponent', () => { waiting.dispose(); }); + it('shows the bound model in the row stats once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + startAgent(running, 'call_agent_1', 'explore'); + + group.attach('call_agent_1', running); + expect(renderText(group)).toContain('explore · inspect project · 0 tools'); + + running.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + // Non-phase updates are throttled; flush the pending refresh. + vi.runOnlyPendingTimers(); + expect(renderText(group)).toContain('explore · inspect project · Kimi K2.5 · 0 tools'); + + group.dispose(); + running.dispose(); + }); + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -215,7 +235,7 @@ describe('AgentGroupComponent', () => { group.attach('call_agent_1', explore); const out = renderText(group); - expect(out).toContain('explore · example/test-model · inspect project · 0 tools · 0s · Running'); + expect(out).toContain('explore · inspect project · example/test-model · 0 tools · 0s · Running'); group.dispose(); explore.dispose(); diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index 3a96b30c06..3630172d76 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -167,27 +167,38 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); - it('shows the shared model in the header when members share one', () => { + it('shows the bound model display name in the header', () => { const component = createComponent(); - component.registerSubagent({ agentId: 'agent-1', model: 'example/model-a' }); - component.registerSubagent({ agentId: 'agent-2', model: 'example/model-a' }); + + component.setMemberModel('agent-1', 'kimi-k2-thinking'); + const lines = renderLines(component); + const headerLine = lines.find((line) => line.includes('Agent Swarm')); + + expect(headerLine).toBeDefined(); + expect(headerLine).toContain('Review changed files ─ kimi-k2-thinking'); + }); + + it('shows the header model while every reported member agrees', () => { + const component = createComponent(); + + component.setMemberModel('agent-1', 'kimi-k2-thinking'); + component.setMemberModel('agent-2', 'kimi-k2-thinking'); const output = renderText(component); - expect(output).toContain('Agent Swarm'); - expect(output).toContain('example/model-a'); + expect(output).toContain('kimi-k2-thinking'); }); - it('omits the model from the header when members disagree', () => { + it('omits the header model when members report different bindings', () => { const component = createComponent(); - component.registerSubagent({ agentId: 'agent-1', model: 'example/model-a' }); - component.registerSubagent({ agentId: 'agent-2', model: 'example/model-b' }); + + component.setMemberModel('agent-1', 'kimi-k2-thinking'); + component.setMemberModel('agent-2', 'other-model'); const output = renderText(component); - expect(output).toContain('Agent Swarm'); - expect(output).not.toContain('example/model-a'); - expect(output).not.toContain('example/model-b'); + expect(output).not.toContain('kimi-k2-thinking'); + expect(output).not.toContain('other-model'); }); it('repaints from the active palette when the theme changes', () => { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 34eaaed7dd..0e81fda892 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -41,10 +41,10 @@ describe('status panel report lines', () => { summary: null, limits: [ { - label: '5h limit', + window: { duration: 5, unit: 'hour' }, used: 8, limit: 100, - resetHint: 'resets in 1h', + resetAt: new Date(Date.now() + 3600_000).toISOString(), }, ], }, @@ -62,6 +62,7 @@ describe('status panel report lines', () => { expect(output).toContain('25%'); expect(output).toContain('(2.9k / 11.7k)'); expect(output).toContain('Plan usage'); + expect(output).toContain('5h limit'); expect(output).toContain('8% used'); expect(output).not.toContain('Account'); expect(output).not.toContain('AGENTS.md'); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 8936ccb812..4426e0e5a4 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1033,57 +1033,32 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('summary fallback'); }); - it('shows the subagent model in the header when spawned with a model', () => { + it('shows the bound model in the subagent header and group snapshot once reported', () => { vi.useFakeTimers(); vi.setSystemTime(10_000); const component = new ToolCallComponent( { id: 'call_agent_model', name: 'Agent', - args: { description: 'explore project xxx' }, + args: { description: 'explore project' }, }, undefined, ); - component.onSubagentSpawned({ - agentId: 'sub_explore_model', - agentName: 'explore', - runInBackground: false, - model: 'example/test-model', - }); - - component.onSubagentStarted({ - agentId: 'sub_explore_model', + agentId: 'sub_model_1', agentName: 'explore', runInBackground: false, - model: 'example/test-model', }); - const out = strip(component.render(120).join('\n')); - expect(out).toContain('Explore Agent Running (explore project xxx) · example/test-model · 0 tools · 0s'); - }); - - it('omits the model chip when the subagent has no model', () => { - vi.useFakeTimers(); - vi.setSystemTime(10_000); - const component = new ToolCallComponent( - { - id: 'call_agent_no_model', - name: 'Agent', - args: { description: 'explore project xxx' }, - }, - undefined, - ); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · 0 tools'); + expect(out).not.toContain('Kimi K2.5'); - component.onSubagentSpawned({ - agentId: 'sub_explore_no_model', - agentName: 'explore', - runInBackground: false, - }); + component.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); - const out = strip(component.render(120).join('\n')); - expect(out).not.toContain('local/'); - expect(out).toContain('Explore Agent Queued (explore project xxx) · 0 tools · 0s'); + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · Kimi K2.5 · 0 tools'); + expect(component.getSubagentSnapshot().model).toBe('Kimi K2.5'); }); it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 199031896c..29cb4ac7de 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,5 +1,5 @@ import { visibleWidth } from '@moonshot-ai/pi-tui'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; @@ -14,38 +14,93 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { - const lines = buildUsageReportLines({ - sessionUsage: { - byModel: { - kimi: { - inputOther: 1000, - inputCacheRead: 500, - inputCacheCreation: 500, - output: 250, + // Freeze the clock so the resetAt fixture is an exact hour out — with a + // live clock the elapsed milliseconds floor the diff down to 59m. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00Z')); + try { + const lines = buildUsageReportLines({ + sessionUsage: { + byModel: { + kimi: { + inputOther: 1000, + inputCacheRead: 500, + inputCacheCreation: 500, + output: 250, + }, + }, + }, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + managedUsage: { + summary: { + name: 'daily', + used: 20, + limit: 100, + resetAt: new Date(Date.now() + 3600_000).toISOString(), }, + limits: [], }, + }).map(strip); + + expect(lines).toContain('Session usage'); + expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); + expect(lines).toContain('Context window'); + expect(lines.join('\n')).toContain('25%'); + expect(lines).toContain('Plan usage'); + expect(lines.join('\n')).toContain('daily'); + expect(lines.join('\n')).toContain('20% used'); + expect(lines.join('\n')).toContain('resets in 1h'); + } finally { + vi.useRealTimers(); + } + }); + + it('derives plan usage labels from the window and falls back to name / Limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: { window: { duration: 1, unit: 'week' }, used: 1, limit: 10 }, + limits: [ + { window: { duration: 5, unit: 'hour' }, used: 2, limit: 10 }, + { name: 'Custom cap', used: 3, limit: 10 }, + { used: 4, limit: 10 }, + ], }, - contextUsage: 0.25, - contextTokens: 2500, - maxContextTokens: 10000, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Weekly limit'); + expect(output).toContain('5h limit'); + expect(output).toContain('Custom cap'); + expect(output).toContain('Limit'); + }); + + it('shows "reset" when the reset timestamp is already in the past', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, managedUsage: { - summary: { - label: 'daily', - used: 20, - limit: 100, - resetHint: 'resets tomorrow', - }, - limits: [], + summary: null, + limits: [ + { + name: 'daily', + used: 1, + limit: 10, + resetAt: new Date(Date.now() - 60_000).toISOString(), + }, + ], }, }).map(strip); - expect(lines).toContain('Session usage'); - expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); - expect(lines).toContain('Context window'); - expect(lines.join('\n')).toContain('25%'); - expect(lines).toContain('Plan usage'); - expect(lines.join('\n')).toContain('20% used'); - expect(lines.join('\n')).toContain('resets tomorrow'); + expect(lines.join('\n')).toContain('reset'); + expect(lines.join('\n')).not.toContain('resets in'); }); it('formats extra usage with a monthly limit', () => { 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/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 2dae019b0c..0201a57863 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', 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 d8272d9a50..b7c17434e1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -96,6 +96,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, ...tuiConfig, }, version: '0.0.0-test', @@ -235,8 +236,8 @@ describe('KimiTUI managed usage runtime', () => { it('refreshes quota when the active alias resolves from a custom provider to managed', async () => { const usage = { kind: 'ok' as const, - summary: { label: '1w limit', used: 12, limit: 100 }, - limits: [{ label: '5h limit', used: 40, limit: 100 }], + summary: { window: { duration: 1, unit: 'week' }, used: 12, limit: 100 }, + limits: [{ window: { duration: 5, unit: 'hour' }, used: 40, limit: 100 }], extraUsage: null, }; const getManagedUsage = vi.fn(async () => usage); @@ -283,7 +284,7 @@ describe('KimiTUI managed usage runtime', () => { it('clears quota and drops a delayed response when the active alias resolves to custom', async () => { const pending = deferred<{ readonly kind: 'ok'; - readonly summary: { readonly label: string; readonly used: number; readonly limit: number }; + readonly summary: { readonly name: string; readonly used: number; readonly limit: number }; readonly limits: readonly []; readonly extraUsage: null; }>(); @@ -307,7 +308,7 @@ describe('KimiTUI managed usage runtime', () => { }, }, managedUsage: { - summary: { label: 'old limit', used: 90, limit: 100 }, + summary: { name: 'old', used: 90, limit: 100 }, limits: [], extraUsage: null, }, @@ -325,7 +326,7 @@ describe('KimiTUI managed usage runtime', () => { }); pending.resolve({ kind: 'ok', - summary: { label: 'stale limit', used: 10, limit: 100 }, + summary: { name: 'stale', used: 10, limit: 100 }, limits: [], extraUsage: null, }); @@ -395,6 +396,24 @@ describe('KimiTUI startup', () => { }); }); + it('binds the resolved agent profile and agent files to the startup session', async () => { + const session = makeSession(); + const harness = makeHarness(session); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + expect(driver.state.startupState).toBe('ready'); + }); + it('resumes the latest session for --continue and marks history for replay', async () => { const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { @@ -1319,6 +1338,43 @@ describe('KimiTUI startup', () => { }); }); + it('carries the agent binding into the post-login startup session', async () => { + const session = makeSession(); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); + + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: undefined, + planMode: undefined, + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + }); + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ 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 91dc5beacf..0cf9c76c81 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -31,6 +31,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/utils/managed-usage-footer.test.ts b/apps/kimi-code/test/tui/utils/managed-usage-footer.test.ts index ff405ecc2e..056055f365 100644 --- a/apps/kimi-code/test/tui/utils/managed-usage-footer.test.ts +++ b/apps/kimi-code/test/tui/utils/managed-usage-footer.test.ts @@ -7,8 +7,8 @@ import { } from '#/tui/utils/managed-usage-footer'; const sampleUsage: ManagedUsageReport = { - summary: { label: '1w limit', used: 12, limit: 100 }, - limits: [{ label: '5h limit', used: 40, limit: 100 }], + summary: { window: { duration: 1, unit: 'week' }, used: 12, limit: 100 }, + limits: [{ window: { duration: 5, unit: 'hour' }, used: 40, limit: 100 }], extraUsage: null, }; @@ -43,20 +43,20 @@ describe('buildManagedUsageFooterView', () => { }); }); - it('strips a trailing "limit" from labels and maps severity by ratio', () => { + it('labels rows from their window or name and maps severity by ratio', () => { const usage: ManagedUsageReport = { - summary: { label: 'Weekly limit', used: 60, limit: 100 }, + summary: { window: { duration: 1, unit: 'week' }, used: 60, limit: 100 }, limits: [ - { label: '5H LIMIT', used: 90, limit: 100 }, - { label: 'daily', used: 0, limit: 50 }, + { window: { duration: 5, unit: 'hour' }, used: 90, limit: 100 }, + { name: 'daily', used: 0, limit: 50 }, ], }; const view = buildManagedUsageFooterView(usage, null); expect(view).toEqual({ kind: 'ok', parts: [ - { text: 'Weekly: 60%', severity: 'warn' }, - { text: '5H: 90%', severity: 'danger' }, + { text: '1w: 60%', severity: 'warn' }, + { text: '5h: 90%', severity: 'danger' }, { text: 'daily: 0%', severity: 'ok' }, ], }); @@ -66,7 +66,7 @@ describe('buildManagedUsageFooterView', () => { const view = buildManagedUsageFooterView( { summary: null, - limits: [{ label: '5h limit', used: 10, limit: 0 }], + limits: [{ window: { duration: 5, unit: 'hour' }, used: 10, limit: 0 }], }, null, ); 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 + +
+ + +
+